feat: 添加自动化任务(automation_jobs)功能模块
This commit is contained in:
@@ -24,6 +24,7 @@ import '../../features/calendar/ui/calendar_state_manager.dart';
|
||||
import '../../features/friends/data/friends_api.dart';
|
||||
import '../../features/messages/data/inbox_api.dart';
|
||||
import '../../features/settings/data/settings_api.dart';
|
||||
import '../../features/settings/data/services/automation_jobs_api.dart';
|
||||
import '../../features/settings/data/services/settings_user_cache.dart';
|
||||
import '../../features/settings/data/services/user_profile_cache_repository.dart';
|
||||
import '../../features/settings/data/services/memory_service.dart';
|
||||
@@ -112,6 +113,9 @@ Future<void> configureDependencies() async {
|
||||
final settingsApi = SettingsApi(apiClient);
|
||||
sl.registerSingleton<SettingsApi>(settingsApi);
|
||||
|
||||
final automationJobsApi = AutomationJobsApi(apiClient);
|
||||
sl.registerSingleton<AutomationJobsApi>(automationJobsApi);
|
||||
|
||||
final memoryService = MemoryService(apiClient);
|
||||
sl.registerSingleton<MemoryService>(memoryService);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../../features/todo/ui/screens/todo_detail_screen.dart';
|
||||
import '../../features/todo/ui/screens/todo_edit_screen.dart';
|
||||
import '../../features/settings/ui/screens/settings_screen.dart';
|
||||
import '../../features/settings/ui/screens/features_screen.dart';
|
||||
import '../../features/settings/ui/screens/job_detail_screen.dart';
|
||||
import '../../features/settings/ui/screens/memory_screen.dart';
|
||||
import '../../features/settings/ui/screens/user_memory_view_screen.dart';
|
||||
import '../../features/settings/ui/screens/work_memory_view_screen.dart';
|
||||
@@ -180,6 +181,15 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
path: AppRoutes.settingsFeatures,
|
||||
builder: (context, state) => const FeaturesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsJobNew,
|
||||
builder: (context, state) => const JobDetailScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/job/:id',
|
||||
builder: (context, state) =>
|
||||
JobDetailScreen(jobId: state.pathParameters['id']),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.settingsMemory,
|
||||
builder: (context, state) => const MemoryScreen(),
|
||||
|
||||
@@ -29,6 +29,8 @@ class AppRoutes {
|
||||
|
||||
static const settingsMain = '/settings';
|
||||
static const settingsFeatures = '/settings/features';
|
||||
static const settingsJobNew = '/settings/job/new';
|
||||
static String settingsJobDetail(String id) => '/settings/job/$id';
|
||||
static const settingsMemory = '/settings/memory';
|
||||
static const settingsMemoryUser = '/settings/memory/user';
|
||||
static const settingsMemoryWork = '/settings/memory/work';
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum Intent { createEvent, searchEvent, unknown }
|
||||
|
||||
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent)
|
||||
final _orderedPatterns = <(RegExp, Intent)>[
|
||||
(RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent),
|
||||
(RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent),
|
||||
(RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent),
|
||||
];
|
||||
|
||||
/// 时区常量
|
||||
const _defaultTimezone = 'Asia/Shanghai';
|
||||
const _dayToday = '今天';
|
||||
const _dayTomorrow = '明天';
|
||||
const _dayAfterTomorrow = '后天';
|
||||
const _tomorrowOffset = 1;
|
||||
const _dayAfterTomorrowOffset = 2;
|
||||
const _defaultMinute = 0;
|
||||
|
||||
class AiDecisionEngine {
|
||||
Intent matchIntent(String text) {
|
||||
for (final (pattern, intent) in _orderedPatterns) {
|
||||
if (pattern.hasMatch(text)) return intent;
|
||||
}
|
||||
return Intent.unknown;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? tryExtractEventArgs(String text) {
|
||||
if (matchIntent(text) != Intent.createEvent) return null;
|
||||
|
||||
final args = <String, dynamic>{};
|
||||
|
||||
final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text);
|
||||
if (titleMatch != null) {
|
||||
args['title'] = titleMatch.group(1)?.trim() ?? text;
|
||||
} else if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) {
|
||||
args['title'] = text
|
||||
.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
final title = args['title'];
|
||||
if (title == null || (title as String).isEmpty) return null;
|
||||
|
||||
final timeMatch = RegExp(
|
||||
r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?',
|
||||
).firstMatch(text);
|
||||
if (timeMatch != null) {
|
||||
final dayStr = timeMatch.group(1) ?? _dayToday;
|
||||
final hour = int.parse(timeMatch.group(2)!);
|
||||
final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute');
|
||||
|
||||
final now = DateTime.now();
|
||||
final dayOffset = switch (dayStr) {
|
||||
_dayTomorrow => _tomorrowOffset,
|
||||
_dayAfterTomorrow => _dayAfterTomorrowOffset,
|
||||
_ => 0,
|
||||
};
|
||||
final startAt = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day + dayOffset,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
|
||||
args['startAt'] = startAt.toIso8601String();
|
||||
args['timezone'] = _defaultTimezone;
|
||||
}
|
||||
|
||||
if (!args.containsKey('startAt')) return null;
|
||||
return args;
|
||||
}
|
||||
|
||||
bool shouldTriggerToolCall(String text) =>
|
||||
matchIntent(text) == Intent.createEvent;
|
||||
|
||||
Map<String, dynamic>? getToolCallArgs(String text) {
|
||||
if (!shouldTriggerToolCall(text)) return null;
|
||||
return tryExtractEventArgs(text);
|
||||
}
|
||||
|
||||
ForceTriggerResult? tryForceTrigger(String text) {
|
||||
final match = RegExp(
|
||||
r'#tool:([A-Za-z0-9_.-]+)\s*(\{.*\})?',
|
||||
).firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final toolName = match.group(1)!;
|
||||
final argsJson = match.group(2);
|
||||
|
||||
Map<String, dynamic>? args;
|
||||
if (argsJson != null) {
|
||||
try {
|
||||
args = jsonDecode(argsJson) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
|
||||
return ForceTriggerResult(toolName: toolName, args: args ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
class ForceTriggerResult {
|
||||
final String toolName;
|
||||
final Map<String, dynamic> args;
|
||||
ForceTriggerResult({required this.toolName, required this.args});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
|
||||
typedef RouteNavigator = void Function(String target, {bool replace});
|
||||
|
||||
const Set<String> _allowedRoutes = {
|
||||
AppRoutes.settingsMain,
|
||||
AppRoutes.todoList,
|
||||
AppRoutes.todoCreate,
|
||||
AppRoutes.calendarDayWeek,
|
||||
AppRoutes.calendarMonth,
|
||||
AppRoutes.calendarEventCreate,
|
||||
AppRoutes.messageInviteList,
|
||||
AppRoutes.contactsList,
|
||||
AppRoutes.contactsAdd,
|
||||
};
|
||||
|
||||
const List<String> _allowedRoutePrefixes = [
|
||||
'/calendar/events/',
|
||||
'/todo/',
|
||||
'/messages/invites/',
|
||||
];
|
||||
|
||||
class RouteNavigationTool {
|
||||
RouteNavigationTool._();
|
||||
|
||||
static final RouteNavigationTool instance = RouteNavigationTool._();
|
||||
|
||||
RouteNavigator? _navigator;
|
||||
|
||||
void bindNavigator(RouteNavigator navigator) {
|
||||
_navigator = navigator;
|
||||
}
|
||||
|
||||
void clearNavigator() {
|
||||
_navigator = null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> execute(Map<String, dynamic> args) {
|
||||
final target = args['target'];
|
||||
if (target is! String || target.isEmpty) {
|
||||
return {'ok': false, 'error': 'target is required'};
|
||||
}
|
||||
if (!_isAllowedTarget(target)) {
|
||||
return {'ok': false, 'target': target, 'error': 'target is not allowed'};
|
||||
}
|
||||
final replace = args['replace'] == true;
|
||||
final navigator = _navigator;
|
||||
if (navigator == null) {
|
||||
return {
|
||||
'ok': false,
|
||||
'target': target,
|
||||
'replace': replace,
|
||||
'error': 'navigator not bound',
|
||||
};
|
||||
}
|
||||
navigator(target, replace: replace);
|
||||
return {'ok': true, 'target': target, 'replace': replace, 'applied': true};
|
||||
}
|
||||
|
||||
bool _isAllowedTarget(String target) {
|
||||
if (!target.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
final normalized = target.split('?').first;
|
||||
if (_allowedRoutes.contains(normalized)) {
|
||||
return true;
|
||||
}
|
||||
for (final prefix in _allowedRoutePrefixes) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'route_navigation_tool.dart';
|
||||
|
||||
typedef ToolHandler =
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
|
||||
|
||||
/// 工具常量
|
||||
const _toolNameNavigateRoute = 'front.navigate_to_route';
|
||||
|
||||
class ToolDefinition {
|
||||
final String name;
|
||||
final String description;
|
||||
final Map<String, dynamic> parameters;
|
||||
final ToolHandler handler;
|
||||
|
||||
ToolDefinition({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.parameters,
|
||||
required this.handler,
|
||||
});
|
||||
}
|
||||
|
||||
class ToolRegistry {
|
||||
static final Map<String, ToolDefinition> _tools = {};
|
||||
static bool _initialized = false;
|
||||
|
||||
static void initialize() {
|
||||
if (_initialized) return;
|
||||
|
||||
_tools[_toolNameNavigateRoute] = ToolDefinition(
|
||||
name: _toolNameNavigateRoute,
|
||||
description: '在前端执行路由跳转',
|
||||
parameters: {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'target': {'type': 'string', 'description': '跳转目标路由'},
|
||||
'replace': {'type': 'boolean', 'description': '是否 replace 导航'},
|
||||
},
|
||||
'required': ['target'],
|
||||
},
|
||||
handler: _handleNavigateRoute,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _handleNavigateRoute(
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
return RouteNavigationTool.instance.execute(args);
|
||||
}
|
||||
|
||||
static ToolDefinition? getTool(String name) => _tools[name];
|
||||
static List<ToolDefinition> getAllTools() => _tools.values.toList();
|
||||
|
||||
static Future<Map<String, dynamic>> execute(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null) throw ToolNotFoundException('Tool not found: $toolName');
|
||||
return tool.handler(args);
|
||||
}
|
||||
|
||||
static ToolValidationResult validateArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null) {
|
||||
return ToolValidationResult(
|
||||
ok: false,
|
||||
error: 'Tool not found: $toolName',
|
||||
);
|
||||
}
|
||||
|
||||
final required = tool.parameters['required'] as List<dynamic>? ?? [];
|
||||
final missing = <String>[];
|
||||
for (final field in required) {
|
||||
if (!args.containsKey(field) || args[field] == null) {
|
||||
missing.add(field as String);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
return ToolValidationResult(
|
||||
ok: false,
|
||||
error: 'Missing required fields: ${missing.join(', ')}',
|
||||
);
|
||||
}
|
||||
return ToolValidationResult(ok: true);
|
||||
}
|
||||
}
|
||||
|
||||
class ToolNotFoundException implements Exception {
|
||||
final String message;
|
||||
ToolNotFoundException(this.message);
|
||||
@override
|
||||
String toString() => 'ToolNotFoundException: $message';
|
||||
}
|
||||
|
||||
class ToolValidationResult {
|
||||
final bool ok;
|
||||
final String? error;
|
||||
ToolValidationResult({required this.ok, this.error});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/utils/tool_name_localizer.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../chat/data/models/chat_list_item.dart';
|
||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||
@@ -248,7 +249,7 @@ class HomeChatItemRenderer {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.toolName,
|
||||
localizeToolName(item.toolName),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
int _parseInt(dynamic v, {required String field, required int fallback}) {
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (v is int) {
|
||||
return v;
|
||||
}
|
||||
if (v is String) {
|
||||
return int.tryParse(v) ?? (throw FormatException('$field invalid'));
|
||||
}
|
||||
throw FormatException('$field invalid type');
|
||||
}
|
||||
|
||||
List<String> _parseStringList(dynamic v, {required String field}) {
|
||||
if (v == null) {
|
||||
return const [];
|
||||
}
|
||||
if (v is List && v.every((e) => e is String)) {
|
||||
return v.cast<String>();
|
||||
}
|
||||
throw FormatException('$field invalid type');
|
||||
}
|
||||
|
||||
String _parseString(
|
||||
dynamic v, {
|
||||
required String field,
|
||||
required String fallback,
|
||||
}) {
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (v is String) {
|
||||
return v;
|
||||
}
|
||||
throw FormatException('$field invalid type');
|
||||
}
|
||||
|
||||
class MessageContextConfigModel {
|
||||
final String source;
|
||||
final String windowMode;
|
||||
final int windowCount;
|
||||
|
||||
MessageContextConfigModel({
|
||||
required this.source,
|
||||
required this.windowMode,
|
||||
required this.windowCount,
|
||||
});
|
||||
|
||||
factory MessageContextConfigModel.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
);
|
||||
}
|
||||
return MessageContextConfigModel(
|
||||
source: _parseString(
|
||||
json['source'],
|
||||
field: 'source',
|
||||
fallback: 'latest_chat',
|
||||
),
|
||||
windowMode: _parseString(
|
||||
json['window_mode'],
|
||||
field: 'window_mode',
|
||||
fallback: 'day',
|
||||
),
|
||||
windowCount: _parseInt(
|
||||
json['window_count'],
|
||||
field: 'window_count',
|
||||
fallback: 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'source': source,
|
||||
'window_mode': windowMode,
|
||||
'window_count': windowCount,
|
||||
};
|
||||
|
||||
MessageContextConfigModel copyWith({
|
||||
String? source,
|
||||
String? windowMode,
|
||||
int? windowCount,
|
||||
}) {
|
||||
return MessageContextConfigModel(
|
||||
source: source ?? this.source,
|
||||
windowMode: windowMode ?? this.windowMode,
|
||||
windowCount: windowCount ?? this.windowCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobConfigModel {
|
||||
final String inputTemplate;
|
||||
final List<String> enabledTools;
|
||||
final MessageContextConfigModel context;
|
||||
|
||||
AutomationJobConfigModel({
|
||||
required this.inputTemplate,
|
||||
required this.enabledTools,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel.fromJson(null),
|
||||
);
|
||||
}
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: _parseString(
|
||||
json['input_template'],
|
||||
field: 'input_template',
|
||||
fallback: '',
|
||||
),
|
||||
enabledTools: _parseStringList(
|
||||
json['enabled_tools'],
|
||||
field: 'enabled_tools',
|
||||
),
|
||||
context: json['context'] != null
|
||||
? MessageContextConfigModel.fromJson(
|
||||
json['context'] as Map<String, dynamic>?,
|
||||
)
|
||||
: MessageContextConfigModel.fromJson(null),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'input_template': inputTemplate,
|
||||
'enabled_tools': enabledTools,
|
||||
'context': context.toJson(),
|
||||
};
|
||||
|
||||
AutomationJobConfigModel copyWith({
|
||||
String? inputTemplate,
|
||||
List<String>? enabledTools,
|
||||
MessageContextConfigModel? context,
|
||||
}) {
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: inputTemplate ?? this.inputTemplate,
|
||||
enabledTools: enabledTools ?? this.enabledTools,
|
||||
context: context ?? this.context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobModel {
|
||||
final String id;
|
||||
final String ownerId;
|
||||
final String? bootstrapKey;
|
||||
final String title;
|
||||
final String scheduleType;
|
||||
final String runAt;
|
||||
final String timezone;
|
||||
final String status;
|
||||
final bool isSystem;
|
||||
final AutomationJobConfigModel config;
|
||||
final DateTime nextRunAt;
|
||||
final DateTime? lastRunAt;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
AutomationJobModel({
|
||||
required this.id,
|
||||
required this.ownerId,
|
||||
this.bootstrapKey,
|
||||
required this.title,
|
||||
required this.scheduleType,
|
||||
required this.runAt,
|
||||
required this.timezone,
|
||||
required this.status,
|
||||
required this.isSystem,
|
||||
required this.config,
|
||||
required this.nextRunAt,
|
||||
this.lastRunAt,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory AutomationJobModel.fromJson(Map<String, dynamic> json) {
|
||||
return AutomationJobModel(
|
||||
id: _parseString(json['id'], field: 'id', fallback: ''),
|
||||
ownerId: _parseString(json['owner_id'], field: 'owner_id', fallback: ''),
|
||||
bootstrapKey: json['bootstrap_key'] == null
|
||||
? null
|
||||
: _parseString(
|
||||
json['bootstrap_key'],
|
||||
field: 'bootstrap_key',
|
||||
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',
|
||||
fallback: 'UTC',
|
||||
),
|
||||
status: _parseString(json['status'], field: 'status', fallback: 'active'),
|
||||
isSystem: json['is_system'] == null
|
||||
? false
|
||||
: (json['is_system'] is bool
|
||||
? json['is_system'] as bool
|
||||
: throw FormatException('is_system invalid type')),
|
||||
config: json['config'] != null
|
||||
? AutomationJobConfigModel.fromJson(
|
||||
json['config'] as Map<String, dynamic>?,
|
||||
)
|
||||
: AutomationJobConfigModel.fromJson(null),
|
||||
nextRunAt: json['next_run_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['next_run_at'],
|
||||
field: 'next_run_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: throw FormatException('next_run_at is required'),
|
||||
lastRunAt: json['last_run_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['last_run_at'],
|
||||
field: 'last_run_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: null,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['created_at'],
|
||||
field: 'created_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: throw FormatException('created_at is required'),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['updated_at'],
|
||||
field: 'updated_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: throw FormatException('updated_at is required'),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'owner_id': ownerId,
|
||||
'bootstrap_key': bootstrapKey,
|
||||
'title': title,
|
||||
'schedule_type': scheduleType,
|
||||
'run_at': runAt,
|
||||
'timezone': timezone,
|
||||
'status': status,
|
||||
'is_system': isSystem,
|
||||
'config': config.toJson(),
|
||||
'next_run_at': nextRunAt.toIso8601String(),
|
||||
'last_run_at': lastRunAt?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
AutomationJobModel copyWith({
|
||||
String? id,
|
||||
String? ownerId,
|
||||
String? bootstrapKey,
|
||||
String? title,
|
||||
String? scheduleType,
|
||||
String? runAt,
|
||||
String? timezone,
|
||||
String? status,
|
||||
bool? isSystem,
|
||||
AutomationJobConfigModel? config,
|
||||
DateTime? nextRunAt,
|
||||
DateTime? lastRunAt,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return AutomationJobModel(
|
||||
id: id ?? this.id,
|
||||
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,
|
||||
config: config ?? this.config,
|
||||
nextRunAt: nextRunAt ?? this.nextRunAt,
|
||||
lastRunAt: lastRunAt ?? this.lastRunAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isActive => status.toLowerCase() == 'active';
|
||||
|
||||
bool get isDaily => scheduleType.toLowerCase() == 'daily';
|
||||
|
||||
bool get isWeekly => scheduleType.toLowerCase() == 'weekly';
|
||||
}
|
||||
|
||||
class AutomationJobListResponse {
|
||||
final List<AutomationJobModel> items;
|
||||
|
||||
AutomationJobListResponse({required this.items});
|
||||
|
||||
factory AutomationJobListResponse.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return AutomationJobListResponse(items: const []);
|
||||
}
|
||||
final itemsJson = json['items'];
|
||||
if (itemsJson == null) {
|
||||
return AutomationJobListResponse(items: const []);
|
||||
}
|
||||
if (itemsJson is List) {
|
||||
return AutomationJobListResponse(
|
||||
items: itemsJson
|
||||
.map((e) => AutomationJobModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
throw FormatException('items invalid type');
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'schedule_type': scheduleType,
|
||||
'run_at': runAt,
|
||||
'timezone': timezone,
|
||||
'status': status,
|
||||
'config': config.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
class AutomationJobConfigPatchModel {
|
||||
final String? inputTemplate;
|
||||
final List<String>? enabledTools;
|
||||
final MessageContextConfigModel? context;
|
||||
|
||||
AutomationJobConfigPatchModel({
|
||||
this.inputTemplate,
|
||||
this.enabledTools,
|
||||
this.context,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
if (inputTemplate != null) map['input_template'] = inputTemplate;
|
||||
if (enabledTools != null) map['enabled_tools'] = enabledTools;
|
||||
if (context != null) map['context'] = context!.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,
|
||||
});
|
||||
|
||||
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();
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import '../models/automation_job_model.dart';
|
||||
|
||||
class AutomationJobsApi {
|
||||
final IApiClient _client;
|
||||
static const _prefix = '/api/v1/automation-jobs';
|
||||
|
||||
AutomationJobsApi(this._client);
|
||||
|
||||
Future<List<AutomationJobModel>> list() async {
|
||||
final response = await _client.get(_prefix);
|
||||
final parsed = AutomationJobListResponse.fromJson(response.data);
|
||||
return parsed.items;
|
||||
}
|
||||
|
||||
Future<AutomationJobModel> get(String id) async {
|
||||
final response = await _client.get('$_prefix/$id');
|
||||
return AutomationJobModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<AutomationJobModel> create(AutomationJobCreateRequest request) async {
|
||||
final response = await _client.post(_prefix, data: request.toJson());
|
||||
return AutomationJobModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<AutomationJobModel> update(
|
||||
String id,
|
||||
AutomationJobUpdateRequest request,
|
||||
) async {
|
||||
final data = request.toJson();
|
||||
final response = await _client.patch('$_prefix/$id', data: data);
|
||||
return AutomationJobModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _client.delete('$_prefix/$id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
|
||||
class AutomationJobsState extends Equatable {
|
||||
final List<AutomationJobModel> jobs;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AutomationJobsState({
|
||||
this.jobs = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
List<AutomationJobModel> get systemJobs =>
|
||||
jobs.where((j) => j.isSystem).toList();
|
||||
List<AutomationJobModel> get userJobs =>
|
||||
jobs.where((j) => !j.isSystem).toList();
|
||||
List<AutomationJobModel> get dailyJobs =>
|
||||
jobs.where((j) => j.isDaily).toList();
|
||||
List<AutomationJobModel> get weeklyJobs =>
|
||||
jobs.where((j) => j.isWeekly).toList();
|
||||
bool get canCreateMore => userJobs.length < 3;
|
||||
|
||||
AutomationJobsState copyWith({
|
||||
List<AutomationJobModel>? jobs,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AutomationJobsState(
|
||||
jobs: jobs ?? this.jobs,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [jobs, isLoading, error];
|
||||
}
|
||||
|
||||
class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
||||
final AutomationJobsApi _api;
|
||||
|
||||
AutomationJobsCubit(this._api) : super(AutomationJobsState());
|
||||
|
||||
Future<void> loadJobs() async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final jobs = await _api.list();
|
||||
emit(state.copyWith(jobs: jobs, isLoading: false));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteJob(String id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateJobStatus({
|
||||
required String id,
|
||||
required bool enabled,
|
||||
}) async {
|
||||
try {
|
||||
final updated = await _api.update(
|
||||
id,
|
||||
AutomationJobUpdateRequest(status: enabled ? 'active' : 'disabled'),
|
||||
);
|
||||
final nextJobs = state.jobs
|
||||
.map((job) => job.id == id ? updated : job)
|
||||
.toList();
|
||||
emit(state.copyWith(jobs: nextJobs));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
|
||||
class JobDetailState extends Equatable {
|
||||
final AutomationJobModel? job;
|
||||
final bool isLoading;
|
||||
final bool isSaving;
|
||||
final String? error;
|
||||
|
||||
const JobDetailState({
|
||||
this.job,
|
||||
this.isLoading = false,
|
||||
this.isSaving = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
JobDetailState copyWith({
|
||||
AutomationJobModel? job,
|
||||
bool? isLoading,
|
||||
bool? isSaving,
|
||||
String? error,
|
||||
}) {
|
||||
return JobDetailState(
|
||||
job: job ?? this.job,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSaving: isSaving ?? this.isSaving,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [job, isLoading, isSaving, error];
|
||||
}
|
||||
|
||||
class JobDetailCubit extends Cubit<JobDetailState> {
|
||||
final AutomationJobsApi _api;
|
||||
|
||||
JobDetailCubit(this._api) : super(JobDetailState());
|
||||
|
||||
Future<void> loadJob(String id) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final job = await _api.get(id);
|
||||
emit(state.copyWith(job: job, isLoading: false));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateJob(String id, AutomationJobUpdateRequest request) async {
|
||||
emit(state.copyWith(isSaving: true));
|
||||
try {
|
||||
final job = await _api.update(id, request);
|
||||
emit(state.copyWith(job: job, isSaving: false));
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isSaving: false, error: e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deleteJob(String id) async {
|
||||
emit(state.copyWith(isSaving: true, error: null));
|
||||
try {
|
||||
await _api.delete(id);
|
||||
emit(state.copyWith(isSaving: false));
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isSaving: false, error: e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> createJob(AutomationJobCreateRequest request) async {
|
||||
emit(state.copyWith(isSaving: true, error: null));
|
||||
try {
|
||||
final job = await _api.create(request);
|
||||
emit(state.copyWith(job: job, isSaving: false));
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isSaving: false, error: e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../../../../shared/widgets/app_toggle_switch.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
import '../../presentation/cubits/automation_jobs_cubit.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
|
||||
class FeaturesScreen extends StatefulWidget {
|
||||
@@ -12,27 +23,88 @@ class FeaturesScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FeaturesScreenState extends State<FeaturesScreen> {
|
||||
bool _dailyReminderEnabled = true;
|
||||
bool _dailySummaryEnabled = false;
|
||||
bool _weeklyReportEnabled = true;
|
||||
bool _weeklyDigestEnabled = false;
|
||||
late final AutomationJobsCubit _cubit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = AutomationJobsCubit(sl<AutomationJobsApi>());
|
||||
_cubit.loadJobs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '周期计划',
|
||||
onBack: () => context.pop(),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('每日'),
|
||||
const SizedBox(height: 8),
|
||||
_buildDailyList(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionTitle('每周'),
|
||||
const SizedBox(height: 8),
|
||||
_buildWeeklyList(),
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: SettingsPageScaffold(
|
||||
title: '周期计划',
|
||||
onBack: () => context.pop(),
|
||||
body: BlocBuilder<AutomationJobsCubit, AutomationJobsState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.error != null) {
|
||||
return Center(child: Text(state.error!));
|
||||
}
|
||||
return _buildJobList(state);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJobList(AutomationJobsState state) {
|
||||
final dailyJobs = state.dailyJobs;
|
||||
final weeklyJobs = state.weeklyJobs;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('每日'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (dailyJobs.isEmpty)
|
||||
_buildEmptyHint('暂无每日计划')
|
||||
else
|
||||
...dailyJobs.map(_buildJobCard),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSectionTitle('每周'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (weeklyJobs.isEmpty)
|
||||
_buildEmptyHint('暂无每周计划')
|
||||
else
|
||||
...weeklyJobs.map(_buildJobCard),
|
||||
if (state.canCreateMore) ...[
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildCreateButton(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyHint(String text) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate500,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -48,122 +120,95 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDailyList() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildFeatureCard(
|
||||
icon: Icons.alarm,
|
||||
iconColor: const Color(0xFF14B8A6),
|
||||
iconBg: const Color(0xFFECFEFF),
|
||||
iconBorder: const Color(0xFFC9F4F2),
|
||||
title: '会议提醒',
|
||||
subtitle: '每次会议前 15 分钟提醒',
|
||||
value: _dailyReminderEnabled,
|
||||
onChanged: (v) => setState(() => _dailyReminderEnabled = v),
|
||||
Widget _buildJobCard(AutomationJobModel job) {
|
||||
return AppPressable(
|
||||
onTap: () async {
|
||||
await context.push(AppRoutes.settingsJobDetail(job.id));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_cubit.loadJobs();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildFeatureCard(
|
||||
icon: Icons.summarize,
|
||||
iconColor: const Color(0xFF2563EB),
|
||||
iconBg: const Color(0xFFEEF6FF),
|
||||
iconBorder: const Color(0xFFDCEAFF),
|
||||
title: '每日摘要',
|
||||
subtitle: '每天 18:00 发送当日摘要',
|
||||
value: _dailySummaryEnabled,
|
||||
onChanged: (v) => setState(() => _dailySummaryEnabled = v),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: AppSpacing.xxl + AppSpacing.lg,
|
||||
height: AppSpacing.xxl + AppSpacing.lg,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceTertiary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome,
|
||||
size: AppSpacing.lg,
|
||||
color: AppColors.blue500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_buildSubtitle(job),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
AppToggleSwitch(
|
||||
value: job.isActive,
|
||||
onChanged: (next) {
|
||||
if (job.isSystem) {
|
||||
Toast.show(context, '系统预置任务状态不可修改', type: ToastType.info);
|
||||
return;
|
||||
}
|
||||
_cubit.updateJobStatus(id: job.id, enabled: next);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyList() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildFeatureCard(
|
||||
icon: Icons.calendar_view_week,
|
||||
iconColor: AppColors.success,
|
||||
iconBg: const Color(0xFFECFDF5),
|
||||
iconBorder: const Color(0xFFCDEEDC),
|
||||
title: '周报生成',
|
||||
subtitle: '每周一自动生成周报',
|
||||
value: _weeklyReportEnabled,
|
||||
onChanged: (v) => setState(() => _weeklyReportEnabled = v),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildFeatureCard(
|
||||
icon: Icons.article,
|
||||
iconColor: AppColors.warning,
|
||||
iconBg: const Color(0xFFFFF7ED),
|
||||
iconBorder: const Color(0xFFFDE6CD),
|
||||
title: '每周摘要',
|
||||
subtitle: '每周日发送本周活动汇总',
|
||||
value: _weeklyDigestEnabled,
|
||||
onChanged: (v) => setState(() => _weeklyDigestEnabled = v),
|
||||
),
|
||||
],
|
||||
);
|
||||
String _buildSubtitle(AutomationJobModel job) {
|
||||
final statusText = job.isActive ? '已启用' : '未启用';
|
||||
final sourceText = job.isSystem ? '系统预置' : '自定义';
|
||||
return '$sourceText • $statusText';
|
||||
}
|
||||
|
||||
Widget _buildFeatureCard({
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required Color iconBg,
|
||||
required Color iconBorder,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: iconBorder),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: iconColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AppToggleSwitch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
Widget _buildCreateButton() {
|
||||
return AppButton(
|
||||
text: '创建任务',
|
||||
onPressed: () async {
|
||||
await context.push(AppRoutes.settingsJobNew);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_cubit.loadJobs();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_input.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../../../../shared/widgets/app_selection_sheet.dart';
|
||||
import '../../../../shared/widgets/detail_header_action_menu.dart';
|
||||
import '../../../../shared/widgets/destructive_action_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../../shared/utils/tool_name_localizer.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
import '../../presentation/cubits/job_detail_cubit.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
|
||||
class JobDetailScreen extends StatefulWidget {
|
||||
const JobDetailScreen({super.key, this.jobId});
|
||||
|
||||
final String? jobId;
|
||||
|
||||
@override
|
||||
State<JobDetailScreen> createState() => _JobDetailScreenState();
|
||||
}
|
||||
|
||||
enum _JobDetailHeaderAction { delete }
|
||||
|
||||
class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
late final JobDetailCubit _cubit;
|
||||
final TextEditingController _titleController = TextEditingController();
|
||||
final TextEditingController _templateController = TextEditingController();
|
||||
|
||||
String _scheduleType = 'daily';
|
||||
String _timezone = 'Asia/Shanghai';
|
||||
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
|
||||
String _contextSource = 'latest_chat';
|
||||
String _contextWindowMode = 'day';
|
||||
int _contextWindowCount = 2;
|
||||
final Set<String> _selectedTools = <String>{'memory.write', 'memory.forget'};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = JobDetailCubit(sl<AutomationJobsApi>());
|
||||
if (widget.jobId != null) {
|
||||
_cubit.loadJob(widget.jobId!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_templateController.dispose();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocConsumer<JobDetailCubit, JobDetailState>(
|
||||
listener: (context, state) {
|
||||
if (state.error != null) {
|
||||
Toast.show(context, state.error!, type: ToastType.error);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return SettingsPageScaffold(
|
||||
title: '加载中',
|
||||
body: const Center(child: AppLoadingIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final job = state.job;
|
||||
final isEditMode = widget.jobId != null;
|
||||
if (isEditMode && job == null && state.error != null) {
|
||||
return SettingsPageScaffold(
|
||||
title: '任务详情',
|
||||
onBack: () => context.pop(),
|
||||
body: _buildLoadFailedView(state.error!),
|
||||
);
|
||||
}
|
||||
|
||||
return SettingsPageScaffold(
|
||||
title: job?.title ?? '新建周期计划',
|
||||
onBack: () => context.pop(),
|
||||
trailing: job != null && !job.isSystem
|
||||
? _buildHeaderActions(job.id, state)
|
||||
: null,
|
||||
body: job == null
|
||||
? _buildCreateForm(state)
|
||||
: _buildDetailPage(job, state),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadFailedView(String error) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('加载失败'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
error,
|
||||
style: const TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
AppButton(text: '重试', onPressed: () => _cubit.loadJob(widget.jobId!)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailPage(AutomationJobModel job, JobDetailState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildOverviewCard(job),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSectionTitle('计划配置'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildInfoCard([
|
||||
_buildInfoRow('周期', _scheduleLabel(job.scheduleType)),
|
||||
_buildInfoRow('执行时间', _displayRunAt(job.runAt)),
|
||||
_buildInfoRow('时区', job.timezone),
|
||||
_buildInfoRow('状态', job.isActive ? '已启用' : '未启用'),
|
||||
]),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSectionTitle('输入模板'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildTextBlock(job.config.inputTemplate),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSectionTitle('启用工具'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildToolWrap(job.config.enabledTools),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSectionTitle('上下文消息模式'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildInfoCard([
|
||||
_buildInfoRow('来源', _contextSourceLabel(job.config.context.source)),
|
||||
_buildInfoRow(
|
||||
'窗口模式',
|
||||
_windowModeLabel(job.config.context.windowMode),
|
||||
),
|
||||
_buildInfoRow('窗口数量', '${job.config.context.windowCount}'),
|
||||
]),
|
||||
if (!job.isSystem && state.isSaving)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: AppSpacing.lg),
|
||||
child: Center(child: AppLoadingIndicator(size: AppSpacing.lg)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderActions(String jobId, JobDetailState state) {
|
||||
return DetailHeaderActionMenu<_JobDetailHeaderAction>(
|
||||
items: const [
|
||||
DetailHeaderActionItem<_JobDetailHeaderAction>(
|
||||
value: _JobDetailHeaderAction.delete,
|
||||
label: '删除',
|
||||
icon: Icons.delete_outline,
|
||||
isDestructive: true,
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
if (state.isSaving) {
|
||||
return;
|
||||
}
|
||||
if (action == _JobDetailHeaderAction.delete) {
|
||||
unawaited(_confirmAndDelete(jobId));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmAndDelete(String jobId) async {
|
||||
final confirmed = await showDestructiveActionSheet(
|
||||
context,
|
||||
title: '删除周期计划',
|
||||
message: '删除后将无法恢复,是否继续?',
|
||||
confirmText: '确认删除',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
final success = await _cubit.deleteJob(jobId);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (success) {
|
||||
Toast.show(context, '删除成功', type: ToastType.success);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOverviewCard(AutomationJobModel job) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.white, AppColors.surfaceInfoLight],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: [
|
||||
_buildBadge(job.isSystem ? '系统预置' : '自定义'),
|
||||
_buildBadge(job.isActive ? '已启用' : '未启用'),
|
||||
_buildBadge(_scheduleLabel(job.scheduleType)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadge(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateForm(JobDetailState state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCreateBasicSection(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildCreateRuleSection(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildCreateToolSection(),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildCreateContextSection(),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
AppButton(
|
||||
text: '创建任务',
|
||||
isLoading: state.isSaving,
|
||||
onPressed: state.isSaving ? null : _submitCreate,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateBasicSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('基本信息'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
AppInput(label: '任务名称', hint: '请输入任务名称', controller: _titleController),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
AppInput(
|
||||
label: '输入模板',
|
||||
hint: '例如:请总结今天的记忆内容',
|
||||
controller: _templateController,
|
||||
maxLines: 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateRuleSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('执行规则'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildPickerTile(
|
||||
label: '周期',
|
||||
value: _scheduleLabel(_scheduleType),
|
||||
onTap: _pickScheduleType,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildPickerTile(
|
||||
label: '执行时间',
|
||||
value: _formatTime(_runAt),
|
||||
onTap: _pickRunAt,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildPickerTile(label: '时区', value: _timezone, onTap: _pickTimezone),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateToolSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('工具选择'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildToolSelector(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateContextSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('上下文消息模式'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildPickerTile(
|
||||
label: '来源',
|
||||
value: _contextSourceLabel(_contextSource),
|
||||
onTap: _pickContextSource,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildPickerTile(
|
||||
label: '窗口模式',
|
||||
value: _windowModeLabel(_contextWindowMode),
|
||||
onTap: _pickWindowMode,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildCounterTile(
|
||||
label: '窗口数量',
|
||||
value: _contextWindowCount,
|
||||
onMinus: _contextWindowCount > 1
|
||||
? () {
|
||||
setState(() {
|
||||
_contextWindowCount -= 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onPlus: _contextWindowCount < 200
|
||||
? () {
|
||||
setState(() {
|
||||
_contextWindowCount += 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerTile({
|
||||
required String label,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return AppPressable(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate500,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate800,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.keyboard_arrow_down, color: AppColors.slate400),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCounterTile({
|
||||
required String label,
|
||||
required int value,
|
||||
required VoidCallback? onMinus,
|
||||
required VoidCallback? onPlus,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$label:$value',
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate800,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildCounterAction(icon: Icons.remove, onTap: onMinus),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_buildCounterAction(icon: Icons.add, onTap: onPlus),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCounterAction({
|
||||
required IconData icon,
|
||||
required VoidCallback? onTap,
|
||||
}) {
|
||||
return AppPressable(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: Container(
|
||||
width: AppSpacing.xxl + AppSpacing.md,
|
||||
height: AppSpacing.xxl + AppSpacing.md,
|
||||
decoration: BoxDecoration(
|
||||
color: onTap == null ? AppColors.slate100 : AppColors.surfaceTertiary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: AppSpacing.lg,
|
||||
color: onTap == null ? AppColors.slate300 : AppColors.blue500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolSelector() {
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: automationToolOptions.map((toolName) {
|
||||
final selected = _selectedTools.contains(toolName);
|
||||
return AppPressable(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTools.remove(toolName);
|
||||
} else {
|
||||
_selectedTools.add(toolName);
|
||||
}
|
||||
});
|
||||
},
|
||||
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(
|
||||
localizeToolName(toolName),
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.blue600 : AppColors.slate600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolWrap(List<String> tools) {
|
||||
if (tools.isEmpty) {
|
||||
return _buildTextBlock('未启用工具');
|
||||
}
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: tools
|
||||
.map((tool) => _buildBadge(localizeToolName(tool)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextBlock(String text) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate700,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(List<Widget> children) {
|
||||
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: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate500,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate800,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickScheduleType() async {
|
||||
final picked = await showAppSelectionSheet<String>(
|
||||
context,
|
||||
title: '选择周期',
|
||||
selectedValue: _scheduleType,
|
||||
items: const [
|
||||
AppSelectionItem(value: 'daily', label: '每日'),
|
||||
AppSelectionItem(value: 'weekly', label: '每周'),
|
||||
],
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_scheduleType = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickTimezone() async {
|
||||
final picked = await showAppSelectionSheet<String>(
|
||||
context,
|
||||
title: '选择时区',
|
||||
selectedValue: _timezone,
|
||||
items: const [
|
||||
AppSelectionItem(value: 'Asia/Shanghai', label: 'Asia/Shanghai'),
|
||||
AppSelectionItem(value: 'UTC', label: 'UTC'),
|
||||
],
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_timezone = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickContextSource() async {
|
||||
final picked = await showAppSelectionSheet<String>(
|
||||
context,
|
||||
title: '选择上下文来源',
|
||||
selectedValue: _contextSource,
|
||||
items: const [AppSelectionItem(value: 'latest_chat', label: '最近聊天')],
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_contextSource = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickWindowMode() async {
|
||||
final picked = await showAppSelectionSheet<String>(
|
||||
context,
|
||||
title: '选择窗口模式',
|
||||
selectedValue: _contextWindowMode,
|
||||
items: const [
|
||||
AppSelectionItem(value: 'day', label: '按天数'),
|
||||
AppSelectionItem(value: 'number', label: '按消息数'),
|
||||
],
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_contextWindowMode = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickRunAt() async {
|
||||
final picked = await showTimePicker(context: context, initialTime: _runAt);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_runAt = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(TimeOfDay time) {
|
||||
final hour = time.hour.toString().padLeft(2, '0');
|
||||
final minute = time.minute.toString().padLeft(2, '0');
|
||||
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 _scheduleLabel(String scheduleType) {
|
||||
final normalized = scheduleType.toLowerCase();
|
||||
if (normalized == 'daily') {
|
||||
return '每日';
|
||||
}
|
||||
if (normalized == 'weekly') {
|
||||
return '每周';
|
||||
}
|
||||
return scheduleType;
|
||||
}
|
||||
|
||||
String _contextSourceLabel(String source) {
|
||||
if (source == 'latest_chat') {
|
||||
return '最近聊天';
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
String _windowModeLabel(String mode) {
|
||||
if (mode == 'day') {
|
||||
return '按天数';
|
||||
}
|
||||
if (mode == 'number') {
|
||||
return '按消息数';
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
Future<void> _submitCreate() async {
|
||||
final title = _titleController.text.trim();
|
||||
final template = _templateController.text.trim();
|
||||
if (title.isEmpty || template.isEmpty) {
|
||||
Toast.show(context, '请填写完整信息', type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final request = AutomationJobCreateRequest(
|
||||
title: title,
|
||||
scheduleType: _scheduleType,
|
||||
runAt: _formatTime(_runAt),
|
||||
timezone: _timezone,
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: template,
|
||||
enabledTools: _selectedTools.toList(),
|
||||
context: MessageContextConfigModel(
|
||||
source: _contextSource,
|
||||
windowMode: _contextWindowMode,
|
||||
windowCount: _contextWindowCount,
|
||||
),
|
||||
),
|
||||
);
|
||||
final success = await _cubit.createJob(request);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (success) {
|
||||
Toast.show(context, '创建成功', type: ToastType.success);
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
||||
import 'package:social_app/features/friends/data/friends_api.dart';
|
||||
import 'package:social_app/features/settings/data/settings_api.dart';
|
||||
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
|
||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||
@@ -34,12 +35,15 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final FriendsApi _friendsApi = sl<FriendsApi>();
|
||||
final AutomationJobsApi _automationJobsApi = sl<AutomationJobsApi>();
|
||||
final SettingsUserCache _userCache = sl<SettingsUserCache>();
|
||||
|
||||
UserResponse? _user;
|
||||
bool _isLoading = true;
|
||||
int _friendsCount = 0;
|
||||
String? _firstFriendName;
|
||||
int _enabledJobsCount = 0;
|
||||
String? _firstEnabledJobTitle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -83,6 +87,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
} catch (e) {
|
||||
// Keep profile available even when contacts fail.
|
||||
}
|
||||
|
||||
try {
|
||||
final jobs = await _automationJobsApi.list();
|
||||
final enabledJobs = jobs.where((job) => job.isActive).toList();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_enabledJobsCount = enabledJobs.length;
|
||||
_firstEnabledJobTitle = enabledJobs.isNotEmpty
|
||||
? enabledJobs.first.title
|
||||
: null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep profile available even when automation jobs fail.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -298,6 +317,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return '已添加 $_friendsCount 位联系人';
|
||||
}
|
||||
|
||||
String _buildAutomationSubtitle() {
|
||||
if (_enabledJobsCount == 0) {
|
||||
return '暂无启用计划';
|
||||
}
|
||||
if (_enabledJobsCount == 1) {
|
||||
return '已启用:${_firstEnabledJobTitle ?? '周期计划'}';
|
||||
}
|
||||
return '已启用 $_enabledJobsCount 个计划';
|
||||
}
|
||||
|
||||
Widget _buildQuickActions(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -314,9 +343,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Expanded(
|
||||
child: _buildActionCard(
|
||||
icon: Icons.auto_awesome,
|
||||
iconColor: AppColors.violet500,
|
||||
iconColor: AppColors.blue500,
|
||||
title: '周期计划',
|
||||
subtitle: '已启用:会议提醒',
|
||||
subtitle: _buildAutomationSubtitle(),
|
||||
onTap: () => context.push(AppRoutes.settingsFeatures),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const Map<String, String> _toolNameZhMap = {
|
||||
'calendar.read': '读取日程',
|
||||
'calendar.write': '写入日程',
|
||||
'calendar.share': '共享日程',
|
||||
'user.lookup': '查找联系人',
|
||||
'memory.write': '写入记忆',
|
||||
'memory.forget': '清理记忆',
|
||||
};
|
||||
|
||||
const Map<String, String> _toolNameAliases = {
|
||||
'calendar_read': 'calendar.read',
|
||||
'calendar_write': 'calendar.write',
|
||||
'calendar_share': 'calendar.share',
|
||||
'user_lookup': 'user.lookup',
|
||||
'memory_write': 'memory.write',
|
||||
'memory_forget': 'memory.forget',
|
||||
};
|
||||
|
||||
const List<String> automationToolOptions = [
|
||||
'calendar.read',
|
||||
'calendar.write',
|
||||
'calendar.share',
|
||||
'user.lookup',
|
||||
'memory.write',
|
||||
'memory.forget',
|
||||
];
|
||||
|
||||
String localizeToolName(String rawName) {
|
||||
final normalized = rawName.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return rawName;
|
||||
}
|
||||
final canonical = _toolNameAliases[normalized] ?? normalized;
|
||||
return _toolNameZhMap[canonical] ?? rawName;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ class AppToggleSwitch extends StatelessWidget {
|
||||
const AppToggleSwitch({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.onChanged,
|
||||
this.activeBackgroundColor,
|
||||
this.inactiveBackgroundColor,
|
||||
this.activeBorderColor,
|
||||
@@ -14,7 +14,7 @@ class AppToggleSwitch extends StatelessWidget {
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final Color? activeBackgroundColor;
|
||||
final Color? inactiveBackgroundColor;
|
||||
final Color? activeBorderColor;
|
||||
@@ -23,32 +23,35 @@ class AppToggleSwitch extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: AppSpacing.xxl + AppSpacing.xl,
|
||||
height: AppSpacing.xl + AppSpacing.xs,
|
||||
padding: const EdgeInsets.all(AppSpacing.xs / 2),
|
||||
decoration: BoxDecoration(
|
||||
color: value
|
||||
? (activeBackgroundColor ?? AppColors.blue100)
|
||||
: (inactiveBackgroundColor ?? AppColors.surfaceTertiary),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(
|
||||
onTap: onChanged == null ? null : () => onChanged!(!value),
|
||||
child: Opacity(
|
||||
opacity: onChanged == null ? 0.55 : 1,
|
||||
child: Container(
|
||||
width: AppSpacing.xxl + AppSpacing.xl,
|
||||
height: AppSpacing.xl + AppSpacing.xs,
|
||||
padding: const EdgeInsets.all(AppSpacing.xs / 2),
|
||||
decoration: BoxDecoration(
|
||||
color: value
|
||||
? (activeBorderColor ?? AppColors.blue300)
|
||||
: (inactiveBorderColor ?? AppColors.borderSecondary),
|
||||
? (activeBackgroundColor ?? AppColors.blue100)
|
||||
: (inactiveBackgroundColor ?? AppColors.surfaceTertiary),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(
|
||||
color: value
|
||||
? (activeBorderColor ?? AppColors.blue300)
|
||||
: (inactiveBorderColor ?? AppColors.borderSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: AppSpacing.lg + AppSpacing.xs,
|
||||
height: AppSpacing.lg + AppSpacing.xs,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: AppSpacing.lg + AppSpacing.xs,
|
||||
height: AppSpacing.lg + AppSpacing.xs,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
|
||||
|
||||
void main() {
|
||||
late AiDecisionEngine engine;
|
||||
|
||||
setUp(() {
|
||||
engine = AiDecisionEngine();
|
||||
});
|
||||
|
||||
group('matchIntent', () {
|
||||
test('returns searchEvent for "今天有什么日程"', () {
|
||||
expect(engine.matchIntent('今天有什么日程'), Intent.searchEvent);
|
||||
});
|
||||
|
||||
test('returns searchEvent for "查看日程"', () {
|
||||
expect(engine.matchIntent('查看日程'), Intent.searchEvent);
|
||||
});
|
||||
|
||||
test('returns searchEvent for "查询安排"', () {
|
||||
expect(engine.matchIntent('查询安排'), Intent.searchEvent);
|
||||
});
|
||||
|
||||
test('returns createEvent for "提醒我明天开会"', () {
|
||||
expect(engine.matchIntent('提醒我明天开会'), Intent.createEvent);
|
||||
});
|
||||
|
||||
test('returns createEvent for "安排时间"', () {
|
||||
expect(engine.matchIntent('安排时间'), Intent.createEvent);
|
||||
});
|
||||
|
||||
test('returns createEvent for time pattern "明天10点"', () {
|
||||
expect(engine.matchIntent('明天10点'), Intent.createEvent);
|
||||
});
|
||||
|
||||
test('returns unknown for "你好"', () {
|
||||
expect(engine.matchIntent('你好'), Intent.unknown);
|
||||
});
|
||||
|
||||
test('returns unknown for random text', () {
|
||||
expect(engine.matchIntent('随便说点什么'), Intent.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldTriggerToolCall', () {
|
||||
test('returns false for "你好"', () {
|
||||
expect(engine.shouldTriggerToolCall('你好'), false);
|
||||
});
|
||||
|
||||
test('returns false for search intent', () {
|
||||
expect(engine.shouldTriggerToolCall('今天有什么日程'), false);
|
||||
});
|
||||
|
||||
test('returns true for create event intent', () {
|
||||
expect(engine.shouldTriggerToolCall('提醒我明天开会'), true);
|
||||
});
|
||||
|
||||
test('returns true for time pattern', () {
|
||||
expect(engine.shouldTriggerToolCall('明天10点开会'), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('tryExtractEventArgs', () {
|
||||
test('returns map with title and startAt for "提醒我明天10点开会"', () {
|
||||
final result = engine.tryExtractEventArgs('提醒我明天10点开会');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!['title'], isNotNull);
|
||||
expect(result['startAt'], isNotNull);
|
||||
expect(result['timezone'], 'Asia/Shanghai');
|
||||
});
|
||||
|
||||
test('returns null for "你好"', () {
|
||||
expect(engine.tryExtractEventArgs('你好'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for search intent', () {
|
||||
expect(engine.tryExtractEventArgs('今天有什么日程'), isNull);
|
||||
});
|
||||
|
||||
test('extracts title correctly', () {
|
||||
final result = engine.tryExtractEventArgs('提醒我开会明天10点');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!['title'], contains('开会'));
|
||||
});
|
||||
|
||||
test('parses today time correctly', () {
|
||||
final result = engine.tryExtractEventArgs('开会今天14:30');
|
||||
final now = DateTime.now();
|
||||
|
||||
expect(result, isNotNull);
|
||||
final startAt = DateTime.parse(result!['startAt'] as String);
|
||||
expect(startAt.year, now.year);
|
||||
expect(startAt.month, now.month);
|
||||
expect(startAt.day, now.day);
|
||||
expect(startAt.hour, 14);
|
||||
expect(startAt.minute, 30);
|
||||
});
|
||||
|
||||
test('parses tomorrow time correctly', () {
|
||||
final result = engine.tryExtractEventArgs('开会明天9点');
|
||||
final now = DateTime.now();
|
||||
final expectedTomorrow = DateTime(now.year, now.month, now.day + 1);
|
||||
|
||||
expect(result, isNotNull);
|
||||
final startAt = DateTime.parse(result!['startAt'] as String);
|
||||
expect(startAt.day, equals(expectedTomorrow.day));
|
||||
expect(startAt.hour, 9);
|
||||
expect(startAt.minute, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('tryForceTrigger', () {
|
||||
test(
|
||||
'returns ForceTriggerResult for "#tool:front.navigate_to_route {}"',
|
||||
() {
|
||||
final result = engine.tryForceTrigger(
|
||||
'#tool:front.navigate_to_route {}',
|
||||
);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.toolName, 'front.navigate_to_route');
|
||||
expect(result.args, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
|
||||
() {
|
||||
final result = engine.tryForceTrigger('#tool:custom {"key": "value"}');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.toolName, 'custom');
|
||||
expect(result.args['key'], 'value');
|
||||
},
|
||||
);
|
||||
|
||||
test('returns null for normal text', () {
|
||||
expect(engine.tryForceTrigger('普通文本'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for empty string', () {
|
||||
expect(engine.tryForceTrigger(''), isNull);
|
||||
});
|
||||
|
||||
test('handles invalid JSON gracefully', () {
|
||||
final result = engine.tryForceTrigger('#tool:test {invalid json}');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.toolName, 'test');
|
||||
expect(result.args, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('getToolCallArgs', () {
|
||||
test('returns args for create event intent', () {
|
||||
final result = engine.getToolCallArgs('提醒我明天10点开会');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!['title'], isNotNull);
|
||||
expect(result['startAt'], isNotNull);
|
||||
});
|
||||
|
||||
test('returns null for non-create intent', () {
|
||||
expect(engine.getToolCallArgs('你好'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for search intent', () {
|
||||
expect(engine.getToolCallArgs('今天有什么日程'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart';
|
||||
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
ToolRegistry.initialize();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
RouteNavigationTool.instance.clearNavigator();
|
||||
});
|
||||
|
||||
group('getTool', () {
|
||||
test('returns tool definition for front.navigate_to_route', () {
|
||||
final tool = ToolRegistry.getTool('front.navigate_to_route');
|
||||
|
||||
expect(tool, isNotNull);
|
||||
expect(tool!.name, 'front.navigate_to_route');
|
||||
expect(tool.description, isNotEmpty);
|
||||
});
|
||||
|
||||
test('returns null for unknown tool', () {
|
||||
expect(ToolRegistry.getTool('unknown_tool'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('validateArgs', () {
|
||||
test('returns error for empty args (missing target)', () {
|
||||
final result = ToolRegistry.validateArgs('front.navigate_to_route', {});
|
||||
|
||||
expect(result.ok, false);
|
||||
expect(result.error, contains('target'));
|
||||
});
|
||||
|
||||
test('returns ok: true for valid args', () {
|
||||
final result = ToolRegistry.validateArgs('front.navigate_to_route', {
|
||||
'target': '/settings',
|
||||
});
|
||||
|
||||
expect(result.ok, true);
|
||||
expect(result.error, isNull);
|
||||
});
|
||||
|
||||
test('returns error for unknown tool', () {
|
||||
final result = ToolRegistry.validateArgs('unknown_tool', {});
|
||||
|
||||
expect(result.ok, false);
|
||||
expect(result.error, contains('Tool not found'));
|
||||
});
|
||||
});
|
||||
|
||||
group('execute', () {
|
||||
test('throws ToolNotFoundException for unknown tool', () async {
|
||||
expect(
|
||||
() => ToolRegistry.execute('unknown_tool', {}),
|
||||
throwsA(isA<ToolNotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('front.navigate_to_route rejects disallowed target', () async {
|
||||
final result = await ToolRegistry.execute('front.navigate_to_route', {
|
||||
'target': '/admin',
|
||||
});
|
||||
|
||||
expect(result['ok'], false);
|
||||
expect(result['error'], contains('not allowed'));
|
||||
});
|
||||
|
||||
test(
|
||||
'front.navigate_to_route executes allowed target when navigator is bound',
|
||||
() async {
|
||||
String? navigatedTo;
|
||||
bool replaced = false;
|
||||
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
|
||||
navigatedTo = target;
|
||||
replaced = replace;
|
||||
});
|
||||
|
||||
final result = await ToolRegistry.execute('front.navigate_to_route', {
|
||||
'target': '/settings',
|
||||
'replace': true,
|
||||
});
|
||||
|
||||
expect(result['ok'], true);
|
||||
expect(navigatedTo, '/settings');
|
||||
expect(replaced, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('getAllTools', () {
|
||||
test('returns list of tool definitions', () {
|
||||
final tools = ToolRegistry.getAllTools();
|
||||
|
||||
expect(tools, isNotEmpty);
|
||||
expect(tools.any((t) => t.name == 'front.navigate_to_route'), true);
|
||||
expect(tools.any((t) => t.name == 'create_calendar_event'), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_chat_item_renderer.dart';
|
||||
|
||||
void main() {
|
||||
ToolCallItem _toolCallItem(String toolName) {
|
||||
return ToolCallItem(
|
||||
id: 'tc-1',
|
||||
callId: 'tc-1',
|
||||
toolName: toolName,
|
||||
args: const {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime(2026, 1, 1),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pumpToolCallItem(WidgetTester tester, String toolName) async {
|
||||
final widget = MaterialApp(
|
||||
home: Scaffold(body: HomeChatItemRenderer.build(_toolCallItem(toolName))),
|
||||
);
|
||||
await tester.pumpWidget(widget);
|
||||
}
|
||||
|
||||
group('HomeChatItemRenderer tool name localization', () {
|
||||
testWidgets('renders dot style name in Chinese', (tester) async {
|
||||
await _pumpToolCallItem(tester, 'memory.write');
|
||||
expect(find.text('写入记忆'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders snake style alias in Chinese', (tester) async {
|
||||
await _pumpToolCallItem(tester, 'memory_write');
|
||||
expect(find.text('写入记忆'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('falls back to raw name for unknown tool', (tester) async {
|
||||
await _pumpToolCallItem(tester, 'unknown.tool');
|
||||
expect(find.text('unknown.tool'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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', () {
|
||||
final json = {
|
||||
'input_template': 'Hello {{name}}',
|
||||
'enabled_tools': ['tool1', 'tool2'],
|
||||
'context': {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'window_count': 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);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'id': 'job-123',
|
||||
'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,
|
||||
'config': {
|
||||
'input_template': 'Hello',
|
||||
'enabled_tools': ['tool1'],
|
||||
'context': {
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 2,
|
||||
},
|
||||
},
|
||||
'next_run_at': '2024-01-15T09:00:00Z',
|
||||
'last_run_at': '2024-01-14T09:00:00Z',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-14T12:00:00Z',
|
||||
};
|
||||
|
||||
final model = AutomationJobModel.fromJson(json);
|
||||
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobCreateRequest', () {
|
||||
test('toJson serializes correctly', () {
|
||||
final request = AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '10:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: 'Hello',
|
||||
enabledTools: ['tool1'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
|
||||
import 'package:social_app/features/settings/presentation/cubits/automation_jobs_cubit.dart';
|
||||
|
||||
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
|
||||
|
||||
class FakeAutomationJobUpdateRequest extends Fake
|
||||
implements AutomationJobUpdateRequest {}
|
||||
|
||||
void main() {
|
||||
late AutomationJobsCubit cubit;
|
||||
late MockAutomationJobsApi mockApi;
|
||||
|
||||
final testJob = AutomationJobModel(
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeAutomationJobUpdateRequest());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockApi = MockAutomationJobsApi();
|
||||
cubit = AutomationJobsCubit(mockApi);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
group('AutomationJobsCubit', () {
|
||||
test('initial state is correct', () {
|
||||
expect(cubit.state.jobs, isEmpty);
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'loadJobs success emits loading then jobs',
|
||||
build: () {
|
||||
when(() => mockApi.list()).thenAnswer((_) async => [testJob]);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJobs(),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having(
|
||||
(s) => s.isLoading,
|
||||
'isLoading',
|
||||
true,
|
||||
),
|
||||
isA<AutomationJobsState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.jobs, 'jobs', [testJob]),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'loadJobs failure emits loading then error',
|
||||
build: () {
|
||||
when(() => mockApi.list()).thenThrow(Exception('Network error'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJobs(),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having(
|
||||
(s) => s.isLoading,
|
||||
'isLoading',
|
||||
true,
|
||||
),
|
||||
isA<AutomationJobsState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'deleteJob success calls loadJobs to refresh',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenAnswer((_) async {});
|
||||
when(() => mockApi.list()).thenAnswer((_) async => []);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
verify(() => mockApi.list()).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'deleteJob failure emits error without refreshing',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
verifyNever(() => mockApi.list());
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'updateJobStatus success replaces target job',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenAnswer((_) async => testJob.copyWith(status: 'disabled'));
|
||||
return cubit;
|
||||
},
|
||||
seed: () => AutomationJobsState(jobs: [testJob]),
|
||||
act: (c) => c.updateJobStatus(id: '1', enabled: false),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having(
|
||||
(s) => s.jobs.first.status,
|
||||
'updated status',
|
||||
'disabled',
|
||||
),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.update('1', any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<AutomationJobsCubit, AutomationJobsState>(
|
||||
'updateJobStatus failure emits error',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenThrow(Exception('Update failed'));
|
||||
return cubit;
|
||||
},
|
||||
seed: () => AutomationJobsState(jobs: [testJob]),
|
||||
act: (c) => c.updateJobStatus(id: '1', enabled: false),
|
||||
expect: () => [
|
||||
isA<AutomationJobsState>().having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
|
||||
import 'package:social_app/features/settings/presentation/cubits/job_detail_cubit.dart';
|
||||
|
||||
class MockAutomationJobsApi extends Mock implements AutomationJobsApi {}
|
||||
|
||||
class FakeAutomationJobUpdateRequest extends Fake
|
||||
implements AutomationJobUpdateRequest {}
|
||||
|
||||
class FakeAutomationJobCreateRequest extends Fake
|
||||
implements AutomationJobCreateRequest {}
|
||||
|
||||
void main() {
|
||||
late JobDetailCubit cubit;
|
||||
late MockAutomationJobsApi mockApi;
|
||||
|
||||
final testJob = AutomationJobModel(
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakeAutomationJobUpdateRequest());
|
||||
registerFallbackValue(FakeAutomationJobCreateRequest());
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockApi = MockAutomationJobsApi();
|
||||
cubit = JobDetailCubit(mockApi);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
group('JobDetailCubit', () {
|
||||
test('initial state is correct', () {
|
||||
expect(cubit.state.job, isNull);
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.isSaving, isFalse);
|
||||
expect(cubit.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'loadJob success emits loading then job',
|
||||
build: () {
|
||||
when(() => mockApi.get(any())).thenAnswer((_) async => testJob);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.job, 'job', testJob),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'loadJob failure emits loading then error',
|
||||
build: () {
|
||||
when(() => mockApi.get(any())).thenThrow(Exception('Network error'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.loadJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isLoading, 'isLoading', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'updateJob success emits saving then job with saving false',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenAnswer((_) async => testJob);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.job, 'job', testJob),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'updateJob failure emits saving then error',
|
||||
build: () {
|
||||
when(
|
||||
() => mockApi.update(any(), any()),
|
||||
).thenThrow(Exception('Update failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.updateJob('1', AutomationJobUpdateRequest()),
|
||||
expect: () => [
|
||||
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', true),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'deleteJob success emits saving then saving false',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenAnswer((_) async {});
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>().having((s) => s.isSaving, 'isSaving', false),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'deleteJob failure emits saving then error',
|
||||
build: () {
|
||||
when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.deleteJob('1'),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.delete('1')).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'createJob success emits saving then created job',
|
||||
build: () {
|
||||
when(() => mockApi.create(any())).thenAnswer((_) async => testJob);
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'daily',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: 'hello',
|
||||
enabledTools: const ['memory.write'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.job, 'job', testJob),
|
||||
],
|
||||
verify: (_) {
|
||||
verify(() => mockApi.create(any())).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
blocTest<JobDetailCubit, JobDetailState>(
|
||||
'createJob failure emits saving then error',
|
||||
build: () {
|
||||
when(() => mockApi.create(any())).thenThrow(Exception('Create failed'));
|
||||
return cubit;
|
||||
},
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'daily',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
inputTemplate: 'hello',
|
||||
enabledTools: const ['memory.write'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
expect: () => [
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
isA<JobDetailState>()
|
||||
.having((s) => s.isSaving, 'isSaving', false)
|
||||
.having((s) => s.error, 'error', isNotNull),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/shared/utils/tool_name_localizer.dart';
|
||||
|
||||
void main() {
|
||||
group('localizeToolName', () {
|
||||
test('translates dot style tool names', () {
|
||||
expect(localizeToolName('memory.write'), '写入记忆');
|
||||
expect(localizeToolName('calendar.read'), '读取日程');
|
||||
});
|
||||
|
||||
test('translates snake style aliases', () {
|
||||
expect(localizeToolName('memory_write'), '写入记忆');
|
||||
expect(localizeToolName('calendar_read'), '读取日程');
|
||||
});
|
||||
|
||||
test('returns raw name for unknown tool', () {
|
||||
expect(localizeToolName('unknown.tool'), 'unknown.tool');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,3 +13,8 @@ context:
|
||||
source: latest_chat
|
||||
window_mode: day
|
||||
window_count: 2
|
||||
schedule:
|
||||
type: daily
|
||||
run_at:
|
||||
hour: 8
|
||||
minute: 0
|
||||
|
||||
@@ -10,21 +10,6 @@ routes:
|
||||
description: Login entry for unauthenticated users.
|
||||
category: auth
|
||||
auth_required: false
|
||||
- route_id: auth.register
|
||||
path: /register
|
||||
description: Account registration page.
|
||||
category: auth
|
||||
auth_required: false
|
||||
- route_id: auth.register_verification
|
||||
path: /register/verification
|
||||
description: Verifies registration code after signup.
|
||||
category: auth
|
||||
auth_required: false
|
||||
- route_id: auth.reset_password
|
||||
path: /reset-password
|
||||
description: Resets password using verification flow.
|
||||
category: auth
|
||||
auth_required: false
|
||||
- route_id: home.main
|
||||
path: /home
|
||||
description: Main assistant home screen.
|
||||
@@ -126,22 +111,44 @@ routes:
|
||||
auth_required: true
|
||||
- route_id: settings.features
|
||||
path: /settings/features
|
||||
description: Cycle planning settings page.
|
||||
description: Automation job list page.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.job_new
|
||||
path: /settings/job/new
|
||||
description: Create page for one automation job.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.job_detail
|
||||
path: /settings/job/{id}
|
||||
description: Detail page for one automation job.
|
||||
category: settings
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: settings.memory
|
||||
path: /settings/memory
|
||||
description: Memory preferences and controls.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.account
|
||||
path: /settings/account
|
||||
description: Account profile and security entry points.
|
||||
- route_id: settings.memory_user
|
||||
path: /settings/memory/user
|
||||
description: User memory summary view.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.change_password
|
||||
path: /change-password
|
||||
description: Password change page.
|
||||
- route_id: settings.memory_work
|
||||
path: /settings/memory/work
|
||||
description: Work memory summary view.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.memory_user_edit
|
||||
path: /settings/memory/user/edit
|
||||
description: Edit user memory details.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.memory_work_edit
|
||||
path: /settings/memory/work/edit
|
||||
description: Edit work memory details.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.edit_profile
|
||||
|
||||
@@ -28,6 +28,20 @@ class MessageContextConfig(BaseModel):
|
||||
window_count: int = Field(default=2, ge=1, le=200)
|
||||
|
||||
|
||||
class ScheduleRunAt(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
hour: int = Field(default=8, ge=0, le=23)
|
||||
minute: int = Field(default=0, ge=0, le=59)
|
||||
|
||||
|
||||
class ScheduleConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: ScheduleType
|
||||
run_at: ScheduleRunAt
|
||||
|
||||
|
||||
class RuntimeConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -35,10 +49,13 @@ class RuntimeConfig(BaseModel):
|
||||
context: MessageContextConfig = Field(default_factory=MessageContextConfig)
|
||||
|
||||
|
||||
class AutomationJobConfig(RuntimeConfig):
|
||||
class AutomationJobConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
input_template: str = Field(..., min_length=1, max_length=4000)
|
||||
enabled_tools: list[AgentTool] | None = Field(default=None, max_length=32)
|
||||
context: MessageContextConfig | None = None
|
||||
input_template: str | None = Field(default=None, min_length=1, max_length=4000)
|
||||
schedule: ScheduleConfig | None = None
|
||||
|
||||
|
||||
class AutomationJob(BaseModel):
|
||||
@@ -59,10 +76,6 @@ class AutomationJob(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@property
|
||||
def is_system(self) -> bool:
|
||||
return self.bootstrap_key is not None
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, obj: OrmAutomationJob) -> "AutomationJob":
|
||||
return cls(
|
||||
@@ -81,3 +94,7 @@ class AutomationJob(BaseModel):
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_system(self) -> bool:
|
||||
return self.bootstrap_key is not None
|
||||
|
||||
@@ -8,7 +8,11 @@ from typing import Any
|
||||
import yaml
|
||||
|
||||
from core.agentscope.tools.tool_config import AgentTool
|
||||
from schemas.automation import AutomationJobConfig, MessageContextConfig
|
||||
from models.automation_jobs import ScheduleType
|
||||
from schemas.automation import (
|
||||
AutomationJobConfig,
|
||||
MessageContextConfig,
|
||||
)
|
||||
|
||||
_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
@@ -43,4 +47,8 @@ def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfi
|
||||
raise ValueError(
|
||||
"memory_extraction context must be latest_chat/day with window_count=2"
|
||||
)
|
||||
if config.schedule is None:
|
||||
raise ValueError("memory_extraction schedule must be configured")
|
||||
if config.schedule.type != ScheduleType.DAILY:
|
||||
raise ValueError("memory_extraction schedule type must be daily")
|
||||
return config
|
||||
|
||||
@@ -22,9 +22,6 @@ from v1.memories.repository import SQLAlchemyMemoriesRepository
|
||||
|
||||
logger = get_logger("v1.auth.registration_bootstrap")
|
||||
|
||||
_LOCAL_RUN_HOUR = 8
|
||||
_LOCAL_RUN_MINUTE = 0
|
||||
|
||||
|
||||
class RegistrationBootstrapRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
@@ -49,6 +46,7 @@ class RegistrationBootstrapRepository:
|
||||
timezone_name: str,
|
||||
run_at: datetime,
|
||||
next_run_at: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> bool:
|
||||
stmt = (
|
||||
insert(AutomationJob)
|
||||
@@ -58,7 +56,7 @@ class RegistrationBootstrapRepository:
|
||||
bootstrap_key=bootstrap_key,
|
||||
title=title,
|
||||
config=config.model_dump(mode="json"),
|
||||
schedule_type=ScheduleType.DAILY,
|
||||
schedule_type=schedule_type,
|
||||
run_at=run_at,
|
||||
next_run_at=next_run_at,
|
||||
timezone=timezone_name,
|
||||
@@ -107,6 +105,7 @@ class RegistrationBootstrapRepositoryLike(Protocol):
|
||||
timezone_name: str,
|
||||
run_at: datetime,
|
||||
next_run_at: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> bool: ...
|
||||
|
||||
async def upsert_initial_memory(
|
||||
@@ -130,6 +129,7 @@ def compute_next_local_time_utc(
|
||||
timezone_name: str,
|
||||
local_hour: int,
|
||||
local_minute: int,
|
||||
schedule_type: ScheduleType,
|
||||
) -> tuple[datetime, datetime]:
|
||||
try:
|
||||
timezone_obj = ZoneInfo(timezone_name)
|
||||
@@ -147,7 +147,10 @@ def compute_next_local_time_utc(
|
||||
if local_now <= today_run_local
|
||||
else today_run_local + timedelta(days=1)
|
||||
)
|
||||
next_local = run_local + timedelta(days=1)
|
||||
if schedule_type == ScheduleType.WEEKLY:
|
||||
next_local = run_local + timedelta(weeks=1)
|
||||
else:
|
||||
next_local = run_local + timedelta(days=1)
|
||||
return run_local.astimezone(UTC), next_local.astimezone(UTC)
|
||||
|
||||
|
||||
@@ -170,9 +173,7 @@ class RegistrationAutomationBootstrapService:
|
||||
{
|
||||
"bootstrap_key": "memory_extraction",
|
||||
"config_name": "memory_extraction",
|
||||
"title": "Memory Agent",
|
||||
"local_hour": _LOCAL_RUN_HOUR,
|
||||
"local_minute": _LOCAL_RUN_MINUTE,
|
||||
"title": "记忆推送",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -197,11 +198,17 @@ class RegistrationAutomationBootstrapService:
|
||||
job_config = load_static_automation_job_config(
|
||||
config_name=str(definition["config_name"])
|
||||
)
|
||||
schedule = job_config.schedule
|
||||
if schedule is None:
|
||||
raise ValueError(
|
||||
f"bootstrap job {bootstrap_key} has no schedule configured"
|
||||
)
|
||||
run_at, next_run_at = compute_next_local_time_utc(
|
||||
now_utc=datetime.now(UTC),
|
||||
timezone_name=timezone_name,
|
||||
local_hour=int(definition["local_hour"]),
|
||||
local_minute=int(definition["local_minute"]),
|
||||
local_hour=schedule.run_at.hour,
|
||||
local_minute=schedule.run_at.minute,
|
||||
schedule_type=schedule.type,
|
||||
)
|
||||
inserted = (
|
||||
await self._repository.insert_bootstrap_automation_job_if_absent(
|
||||
@@ -212,6 +219,7 @@ class RegistrationAutomationBootstrapService:
|
||||
timezone_name=timezone_name,
|
||||
run_at=run_at,
|
||||
next_run_at=next_run_at,
|
||||
schedule_type=schedule.type,
|
||||
)
|
||||
)
|
||||
inserted_any = inserted_any or inserted
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db import get_db
|
||||
from v1.automation_jobs.repository import AutomationJobsRepository
|
||||
from v1.automation_jobs.service import AutomationJobsService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
|
||||
async def get_automation_jobs_repository(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AutomationJobsRepository:
|
||||
return AutomationJobsRepository(session=session)
|
||||
|
||||
|
||||
async def get_automation_jobs_service(
|
||||
repository: Annotated[
|
||||
AutomationJobsRepository, Depends(get_automation_jobs_repository)
|
||||
],
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AutomationJobsService:
|
||||
return AutomationJobsService(repository=repository, session=session)
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> UUID:
|
||||
return current_user.id
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from core.db.base_repository import BaseRepository
|
||||
from models.agent_chat_session import AgentChatSession, SessionType
|
||||
from models.automation_jobs import AutomationJob
|
||||
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from v1.automation_jobs.schemas import (
|
||||
@@ -19,144 +19,10 @@ if TYPE_CHECKING:
|
||||
)
|
||||
|
||||
|
||||
def _compute_next_local_time_utc(
|
||||
*,
|
||||
now_utc: datetime,
|
||||
timezone_name: str,
|
||||
local_hour: int,
|
||||
local_minute: int,
|
||||
) -> tuple[datetime, datetime]:
|
||||
try:
|
||||
timezone_obj = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
timezone_obj = ZoneInfo("UTC")
|
||||
local_now = now_utc.astimezone(timezone_obj)
|
||||
today_run_local = local_now.replace(
|
||||
hour=local_hour,
|
||||
minute=local_minute,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
run_local = (
|
||||
today_run_local
|
||||
if local_now <= today_run_local
|
||||
else today_run_local + timedelta(days=1)
|
||||
)
|
||||
next_local = run_local + timedelta(days=1)
|
||||
return run_local.astimezone(timezone.utc), next_local.astimezone(timezone.utc)
|
||||
|
||||
|
||||
class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
super().__init__(session=session, model=AutomationJob)
|
||||
|
||||
async def list_by_owner(self, owner_id: UUID) -> list[AutomationJob]:
|
||||
stmt = (
|
||||
select(AutomationJob)
|
||||
.where(AutomationJob.owner_id == owner_id)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
.order_by(AutomationJob.created_at.desc())
|
||||
)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
async def get_by_id(self, job_id: UUID) -> AutomationJob | None: # type: ignore[override]
|
||||
stmt = (
|
||||
select(AutomationJob)
|
||||
.where(AutomationJob.id == job_id)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def count_user_jobs(self, owner_id: UUID) -> int:
|
||||
stmt = (
|
||||
select(func.count(AutomationJob.id))
|
||||
.where(AutomationJob.owner_id == owner_id)
|
||||
.where(AutomationJob.bootstrap_key.is_(None))
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
)
|
||||
result = (await self._session.execute(stmt)).scalar_one()
|
||||
return int(result)
|
||||
|
||||
async def create(
|
||||
self, owner_id: UUID, data: "AutomationJobCreateRequest"
|
||||
) -> AutomationJob:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
run_at_dt, next_run_at = _compute_next_local_time_utc(
|
||||
now_utc=now_utc,
|
||||
timezone_name=data.timezone,
|
||||
local_hour=data.run_at.hour,
|
||||
local_minute=data.run_at.minute,
|
||||
)
|
||||
new_job = AutomationJob(
|
||||
id=uuid4(),
|
||||
owner_id=owner_id,
|
||||
created_by=owner_id,
|
||||
bootstrap_key=None,
|
||||
title=data.title,
|
||||
config=data.config.model_dump(mode="json"),
|
||||
schedule_type=data.schedule_type,
|
||||
run_at=run_at_dt,
|
||||
next_run_at=next_run_at,
|
||||
timezone=data.timezone,
|
||||
status=data.status,
|
||||
)
|
||||
self._session.add(new_job)
|
||||
await self._session.flush()
|
||||
return new_job
|
||||
|
||||
async def update(
|
||||
self, job_id: UUID, data: "AutomationJobUpdateRequest"
|
||||
) -> AutomationJob | None:
|
||||
update_values: dict[str, object] = {}
|
||||
if data.title is not None:
|
||||
update_values["title"] = data.title
|
||||
if data.schedule_type is not None:
|
||||
update_values["schedule_type"] = data.schedule_type
|
||||
if data.run_at is not None:
|
||||
stmt = select(AutomationJob).where(AutomationJob.id == job_id)
|
||||
existing = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
return None
|
||||
run_at_dt, next_run_at = _compute_next_local_time_utc(
|
||||
now_utc=datetime.now(timezone.utc),
|
||||
timezone_name=data.timezone or existing.timezone,
|
||||
local_hour=data.run_at.hour,
|
||||
local_minute=data.run_at.minute,
|
||||
)
|
||||
update_values["run_at"] = run_at_dt
|
||||
update_values["next_run_at"] = next_run_at
|
||||
update_values["timezone"] = data.timezone or existing.timezone
|
||||
if data.status is not None:
|
||||
update_values["status"] = data.status
|
||||
if data.config is not None:
|
||||
update_values["config"] = data.config.model_dump(mode="json")
|
||||
|
||||
if not update_values:
|
||||
return await self.get_by_id(job_id)
|
||||
|
||||
stmt = (
|
||||
update(AutomationJob)
|
||||
.where(AutomationJob.id == job_id)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
.values(**update_values)
|
||||
.returning(AutomationJob)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def soft_delete(self, job_id: UUID) -> None:
|
||||
stmt = (
|
||||
update(AutomationJob)
|
||||
.where(AutomationJob.id == job_id)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
.values(deleted_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
|
||||
async def list_due_jobs(
|
||||
self,
|
||||
*,
|
||||
@@ -166,7 +32,7 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
stmt = (
|
||||
select(AutomationJob)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
.where(AutomationJob.status == "active")
|
||||
.where(AutomationJob.status == AutomationJobStatus.ACTIVE)
|
||||
.where(AutomationJob.next_run_at <= now_utc)
|
||||
.order_by(AutomationJob.next_run_at.asc())
|
||||
.limit(max(limit, 1))
|
||||
@@ -213,3 +79,160 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
self._session.add(new_session)
|
||||
await self._session.flush()
|
||||
return new_session.id
|
||||
|
||||
async def list_by_owner(self, owner_id: UUID) -> list[AutomationJob]:
|
||||
stmt = (
|
||||
select(AutomationJob)
|
||||
.where(AutomationJob.owner_id == owner_id)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
.order_by(AutomationJob.created_at.desc())
|
||||
)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
async def count_user_jobs(self, owner_id: UUID) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(AutomationJob)
|
||||
.where(AutomationJob.owner_id == owner_id)
|
||||
.where(AutomationJob.deleted_at.is_(None))
|
||||
.where(AutomationJob.bootstrap_key.is_(None))
|
||||
)
|
||||
result = (await self._session.execute(stmt)).scalar_one()
|
||||
return int(result)
|
||||
|
||||
def _resolve_timezone(self, timezone_str: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(timezone_str)
|
||||
except ZoneInfoNotFoundError:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _compute_initial_next_run_at(
|
||||
self,
|
||||
*,
|
||||
run_at: time,
|
||||
timezone_str: str,
|
||||
now_utc: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> datetime:
|
||||
tz = self._resolve_timezone(timezone_str)
|
||||
local_now = now_utc.astimezone(tz)
|
||||
run_at_local = datetime.combine(local_now.date(), run_at, tz)
|
||||
if run_at_local.tzinfo is None:
|
||||
run_at_local = run_at_local.replace(tzinfo=tz)
|
||||
next_run_at = run_at_local
|
||||
if next_run_at <= local_now:
|
||||
if schedule_type == ScheduleType.DAILY:
|
||||
next_run_at = next_run_at + timedelta(days=1)
|
||||
else:
|
||||
next_run_at = next_run_at + timedelta(weeks=1)
|
||||
return next_run_at.astimezone(timezone.utc)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
owner_id: UUID,
|
||||
data: AutomationJobCreateRequest,
|
||||
) -> AutomationJob:
|
||||
now_utc = datetime.now(tz=timezone.utc)
|
||||
timezone_obj = self._resolve_timezone(data.timezone)
|
||||
local_now = now_utc.astimezone(timezone_obj)
|
||||
date_ref = local_now.date()
|
||||
local_dt = datetime.combine(date_ref, data.run_at, timezone_obj)
|
||||
run_at_datetime = local_dt.astimezone(timezone.utc)
|
||||
next_run_at = self._compute_initial_next_run_at(
|
||||
run_at=data.run_at,
|
||||
timezone_str=data.timezone,
|
||||
now_utc=now_utc,
|
||||
schedule_type=data.schedule_type,
|
||||
)
|
||||
|
||||
new_job = AutomationJob(
|
||||
owner_id=owner_id,
|
||||
created_by=owner_id,
|
||||
bootstrap_key=None,
|
||||
title=data.title,
|
||||
schedule_type=data.schedule_type,
|
||||
run_at=run_at_datetime,
|
||||
timezone=data.timezone,
|
||||
status=data.status,
|
||||
config=data.config.model_dump(mode="json"),
|
||||
next_run_at=next_run_at,
|
||||
)
|
||||
self._session.add(new_job)
|
||||
await self._session.flush()
|
||||
return new_job
|
||||
|
||||
async def update(
|
||||
self,
|
||||
job_id: UUID,
|
||||
data: AutomationJobUpdateRequest,
|
||||
) -> AutomationJob | None:
|
||||
update_values: dict[str, object] = {}
|
||||
existing_job: AutomationJob | None = None
|
||||
|
||||
if data.title is not None:
|
||||
update_values["title"] = data.title
|
||||
if data.schedule_type is not None:
|
||||
update_values["schedule_type"] = data.schedule_type
|
||||
|
||||
should_recompute_schedule = (
|
||||
data.run_at is not None
|
||||
or data.schedule_type is not None
|
||||
or data.timezone is not None
|
||||
)
|
||||
if should_recompute_schedule:
|
||||
now_utc = datetime.now(tz=timezone.utc)
|
||||
if existing_job is None:
|
||||
existing_job = await self.get_by_id(job_id)
|
||||
if existing_job is None:
|
||||
return None
|
||||
|
||||
effective_timezone = data.timezone or existing_job.timezone
|
||||
effective_timezone_obj = self._resolve_timezone(effective_timezone)
|
||||
effective_schedule_type = data.schedule_type or existing_job.schedule_type
|
||||
|
||||
if data.run_at is not None:
|
||||
effective_run_at = data.run_at
|
||||
else:
|
||||
existing_timezone_obj = self._resolve_timezone(existing_job.timezone)
|
||||
effective_run_at = (
|
||||
existing_job.run_at.astimezone(existing_timezone_obj)
|
||||
.time()
|
||||
.replace(microsecond=0)
|
||||
)
|
||||
|
||||
local_now = now_utc.astimezone(effective_timezone_obj)
|
||||
local_dt = datetime.combine(
|
||||
local_now.date(),
|
||||
effective_run_at,
|
||||
effective_timezone_obj,
|
||||
)
|
||||
update_values["run_at"] = local_dt.astimezone(timezone.utc)
|
||||
update_values["next_run_at"] = self._compute_initial_next_run_at(
|
||||
run_at=effective_run_at,
|
||||
timezone_str=effective_timezone,
|
||||
now_utc=now_utc,
|
||||
schedule_type=effective_schedule_type,
|
||||
)
|
||||
if data.timezone is not None:
|
||||
update_values["timezone"] = data.timezone
|
||||
if data.status is not None:
|
||||
update_values["status"] = data.status
|
||||
if data.config is not None:
|
||||
if existing_job is None:
|
||||
existing_job = await self.get_by_id(job_id)
|
||||
if existing_job is None:
|
||||
return None
|
||||
merged_config = {
|
||||
**existing_job.config,
|
||||
**data.config.model_dump(mode="json", exclude_unset=True),
|
||||
}
|
||||
update_values["config"] = merged_config
|
||||
|
||||
if not update_values:
|
||||
return await self.get_by_id(job_id)
|
||||
|
||||
return await self.update_by_id(job_id, update_values)
|
||||
|
||||
async def soft_delete(self, job_id: UUID) -> None:
|
||||
await self.soft_delete_by_id(job_id)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from v1.automation_jobs.dependencies import (
|
||||
get_automation_jobs_service,
|
||||
get_current_user_id,
|
||||
)
|
||||
from v1.automation_jobs.schemas import (
|
||||
AutomationJobCreateRequest,
|
||||
AutomationJobListResponse,
|
||||
AutomationJobResponse,
|
||||
AutomationJobUpdateRequest,
|
||||
)
|
||||
from v1.automation_jobs.service import AutomationJobsService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/automation-jobs", tags=["automation-jobs"])
|
||||
|
||||
|
||||
@router.get("", response_model=AutomationJobListResponse)
|
||||
async def list_automation_jobs(
|
||||
service: Annotated[AutomationJobsService, Depends(get_automation_jobs_service)],
|
||||
current_user_id: Annotated[UUID, Depends(get_current_user_id)],
|
||||
) -> AutomationJobListResponse:
|
||||
return await service.list_by_owner(owner_id=current_user_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=AutomationJobResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_automation_job(
|
||||
request: AutomationJobCreateRequest,
|
||||
service: Annotated[AutomationJobsService, Depends(get_automation_jobs_service)],
|
||||
current_user_id: Annotated[UUID, Depends(get_current_user_id)],
|
||||
) -> AutomationJobResponse:
|
||||
return await service.create(owner_id=current_user_id, data=request)
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=AutomationJobResponse)
|
||||
async def get_automation_job(
|
||||
job_id: UUID,
|
||||
service: Annotated[AutomationJobsService, Depends(get_automation_jobs_service)],
|
||||
current_user_id: Annotated[UUID, Depends(get_current_user_id)],
|
||||
) -> AutomationJobResponse:
|
||||
return await service.get_by_id(job_id=job_id, owner_id=current_user_id)
|
||||
|
||||
|
||||
@router.patch("/{job_id}", response_model=AutomationJobResponse)
|
||||
async def update_automation_job(
|
||||
job_id: UUID,
|
||||
request: AutomationJobUpdateRequest,
|
||||
service: Annotated[AutomationJobsService, Depends(get_automation_jobs_service)],
|
||||
current_user_id: Annotated[UUID, Depends(get_current_user_id)],
|
||||
) -> AutomationJobResponse:
|
||||
return await service.update(job_id=job_id, owner_id=current_user_id, data=request)
|
||||
|
||||
|
||||
@router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_automation_job(
|
||||
job_id: UUID,
|
||||
service: Annotated[AutomationJobsService, Depends(get_automation_jobs_service)],
|
||||
current_user_id: Annotated[UUID, Depends(get_current_user_id)],
|
||||
) -> None:
|
||||
await service.delete(job_id=job_id, owner_id=current_user_id)
|
||||
@@ -3,14 +3,13 @@ from __future__ import annotations
|
||||
from datetime import datetime, time
|
||||
from typing import Self
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from models.automation_jobs import AutomationJob as OrmAutomationJob
|
||||
from models.automation_jobs import AutomationJobStatus, ScheduleType
|
||||
from schemas.automation import (
|
||||
AutomationJobConfig,
|
||||
)
|
||||
from schemas.automation import AutomationJobConfig
|
||||
|
||||
|
||||
class AutomationJobResponse(BaseModel):
|
||||
@@ -61,6 +60,15 @@ class AutomationJobCreateRequest(BaseModel):
|
||||
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
|
||||
config: AutomationJobConfig
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||
return value
|
||||
|
||||
|
||||
class AutomationJobUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -72,6 +80,17 @@ class AutomationJobUpdateRequest(BaseModel):
|
||||
status: AutomationJobStatus | None = None
|
||||
config: AutomationJobConfig | None = None
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||
return value
|
||||
|
||||
|
||||
class AutomationJobListResponse(BaseModel):
|
||||
items: list[AutomationJobResponse]
|
||||
|
||||
@@ -5,14 +5,54 @@ from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from models.automation_jobs import ScheduleType
|
||||
from schemas.automation import AutomationJob as AutomationJobSchema, RuntimeConfig
|
||||
from schemas.automation import (
|
||||
AutomationJob as AutomationJobSchema,
|
||||
MessageContextConfig,
|
||||
RuntimeConfig,
|
||||
)
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from core.logging import get_logger
|
||||
from v1.automation_jobs.schemas import (
|
||||
AutomationJobCreateRequest,
|
||||
AutomationJobListResponse,
|
||||
AutomationJobResponse,
|
||||
AutomationJobUpdateRequest,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from v1.automation_jobs.repository import AutomationJobsRepository
|
||||
|
||||
logger = get_logger("v1.automation_jobs.service")
|
||||
|
||||
|
||||
class AutomationJobLimitExceeded(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Maximum of 3 user jobs allowed",
|
||||
)
|
||||
|
||||
|
||||
class SystemJobModificationForbidden(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="System job cannot be modified",
|
||||
)
|
||||
|
||||
|
||||
class AutomationJobNotFound(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Automation job not found",
|
||||
)
|
||||
|
||||
|
||||
class DispatchFn(Protocol):
|
||||
async def __call__(
|
||||
@@ -46,6 +86,9 @@ class ScanResult:
|
||||
|
||||
|
||||
class AutomationJobsService:
|
||||
_repository: "AutomationJobsRepository"
|
||||
_session: "AsyncSession"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: "AutomationJobsRepository",
|
||||
@@ -71,14 +114,15 @@ class AutomationJobsService:
|
||||
thread_id = await self.get_or_create_chat_session(owner_id=job.owner_id)
|
||||
run_id = f"auto-{job.id}-{int(now_utc.timestamp())}"
|
||||
|
||||
input_text = (job.config.input_template or "").strip()
|
||||
await dispatch_fn(
|
||||
owner_id=job.owner_id,
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
input_text=job.config.input_template.strip(),
|
||||
input_text=input_text,
|
||||
runtime_config=RuntimeConfig(
|
||||
enabled_tools=job.config.enabled_tools,
|
||||
context=job.config.context,
|
||||
enabled_tools=job.config.enabled_tools or [],
|
||||
context=job.config.context or MessageContextConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -102,3 +146,82 @@ class AutomationJobsService:
|
||||
|
||||
async def get_or_create_chat_session(self, *, owner_id: UUID) -> UUID:
|
||||
return await self._repository.get_or_create_chat_session(owner_id=owner_id)
|
||||
|
||||
async def list_by_owner(self, owner_id: UUID) -> AutomationJobListResponse:
|
||||
jobs = await self._repository.list_by_owner(owner_id)
|
||||
return AutomationJobListResponse(
|
||||
items=[AutomationJobResponse.from_orm(job) for job in jobs],
|
||||
)
|
||||
|
||||
async def get_by_id(self, job_id: UUID, owner_id: UUID) -> AutomationJobResponse:
|
||||
job = await self._repository.get_by_id(job_id)
|
||||
if job is None or job.owner_id != owner_id:
|
||||
raise AutomationJobNotFound()
|
||||
return AutomationJobResponse.from_orm(job)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
owner_id: UUID,
|
||||
data: AutomationJobCreateRequest,
|
||||
) -> AutomationJobResponse:
|
||||
try:
|
||||
await self._session.execute(
|
||||
text("SELECT pg_advisory_xact_lock(abs(hashtext(:owner_id)))"),
|
||||
{"owner_id": str(owner_id)},
|
||||
)
|
||||
count = await self._repository.count_user_jobs(owner_id)
|
||||
if count >= 3:
|
||||
await self._session.rollback()
|
||||
raise AutomationJobLimitExceeded()
|
||||
job = await self._repository.create(owner_id, data)
|
||||
await self._session.commit()
|
||||
return AutomationJobResponse.from_orm(job)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to create automation job", owner_id=str(owner_id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Automation job store unavailable",
|
||||
)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
job_id: UUID,
|
||||
owner_id: UUID,
|
||||
data: AutomationJobUpdateRequest,
|
||||
) -> AutomationJobResponse:
|
||||
try:
|
||||
job = await self._repository.get_by_id(job_id)
|
||||
if job is None or job.owner_id != owner_id:
|
||||
raise AutomationJobNotFound()
|
||||
if job.bootstrap_key is not None:
|
||||
raise SystemJobModificationForbidden()
|
||||
updated_job = await self._repository.update(job_id, data)
|
||||
if updated_job is None:
|
||||
raise AutomationJobNotFound()
|
||||
await self._session.commit()
|
||||
return AutomationJobResponse.from_orm(updated_job)
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to update automation job", job_id=str(job_id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Automation job store unavailable",
|
||||
)
|
||||
|
||||
async def delete(self, job_id: UUID, owner_id: UUID) -> None:
|
||||
try:
|
||||
job = await self._repository.get_by_id(job_id)
|
||||
if job is None or job.owner_id != owner_id:
|
||||
raise AutomationJobNotFound()
|
||||
if job.bootstrap_key is not None:
|
||||
raise SystemJobModificationForbidden()
|
||||
await self._repository.soft_delete(job_id)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
logger.exception("Failed to delete automation job", job_id=str(job_id))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Automation job store unavailable",
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter
|
||||
|
||||
from v1.agent.router import router as agent_router
|
||||
from v1.app.router import router as app_router
|
||||
from v1.automation_jobs.router import router as automation_jobs_router
|
||||
from v1.auth.router import router as auth_router
|
||||
from v1.friendships.router import router as friendships_router
|
||||
from v1.inbox_messages.router import router as inbox_messages_router
|
||||
@@ -17,6 +18,7 @@ router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(app_router)
|
||||
router.include_router(auth_router)
|
||||
router.include_router(agent_router)
|
||||
router.include_router(automation_jobs_router)
|
||||
router.include_router(friendships_router)
|
||||
router.include_router(memories_router)
|
||||
router.include_router(users_router)
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import app
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.automation_jobs.dependencies import get_automation_jobs_service
|
||||
from v1.automation_jobs.service import (
|
||||
AutomationJobLimitExceeded,
|
||||
AutomationJobNotFound,
|
||||
)
|
||||
from v1.automation_jobs.schemas import (
|
||||
AutomationJobCreateRequest,
|
||||
AutomationJobListResponse,
|
||||
AutomationJobResponse,
|
||||
AutomationJobUpdateRequest,
|
||||
)
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
|
||||
def _make_job_response(
|
||||
job_id: UUID | None = None, owner_id: UUID | None = None, **overrides
|
||||
) -> AutomationJobResponse:
|
||||
now = datetime.now(timezone.utc)
|
||||
return AutomationJobResponse(
|
||||
id=job_id or uuid4(),
|
||||
owner_id=owner_id or uuid4(),
|
||||
title=overrides.get("title", "Test Job"),
|
||||
schedule_type=overrides.get("schedule_type", "daily"),
|
||||
run_at=overrides.get("run_at", time(9, 0, 0)),
|
||||
timezone=overrides.get("timezone", "Asia/Shanghai"),
|
||||
status=overrides.get("status", "active"),
|
||||
is_system=overrides.get("is_system", False),
|
||||
config=overrides.get(
|
||||
"config", {"input_template": "Hello", "enabled_tools": [], "context": {}}
|
||||
),
|
||||
next_run_at=overrides.get("next_run_at", now),
|
||||
created_at=overrides.get("created_at", now),
|
||||
updated_at=overrides.get("updated_at", now),
|
||||
)
|
||||
|
||||
|
||||
def test_list_automation_jobs_requires_auth() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/automation-jobs")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_list_automation_jobs_returns_empty_when_no_jobs() -> None:
|
||||
class FakeService:
|
||||
async def list_by_owner(self, *, owner_id: UUID) -> AutomationJobListResponse:
|
||||
return AutomationJobListResponse(items=[])
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.get("/api/v1/automation-jobs")
|
||||
assert response.status_code == 401
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_list_automation_jobs_returns_jobs() -> None:
|
||||
user_id = uuid4()
|
||||
job = _make_job_response(owner_id=user_id)
|
||||
|
||||
class FakeService:
|
||||
async def list_by_owner(self, *, owner_id: UUID) -> AutomationJobListResponse:
|
||||
if owner_id == user_id:
|
||||
return AutomationJobListResponse(items=[job])
|
||||
return AutomationJobListResponse(items=[])
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.get("/api/v1/automation-jobs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["title"] == "Test Job"
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_create_automation_job_requires_auth() -> None:
|
||||
class FakeService:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/automation-jobs",
|
||||
json={
|
||||
"title": "New Job",
|
||||
"schedule_type": "daily",
|
||||
"run_at": "09:00:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"config": {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_create_automation_job_succeeds() -> None:
|
||||
user_id = uuid4()
|
||||
new_job = _make_job_response(owner_id=user_id, title="New Job")
|
||||
|
||||
class FakeService:
|
||||
async def create(
|
||||
self, *, owner_id: UUID, data: AutomationJobCreateRequest
|
||||
) -> AutomationJobResponse:
|
||||
return new_job
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/automation-jobs",
|
||||
json={
|
||||
"title": "New Job",
|
||||
"schedule_type": "daily",
|
||||
"run_at": "09:00:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"status": "active",
|
||||
"config": {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == "New Job"
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_create_automation_job_respects_limit() -> None:
|
||||
user_id = uuid4()
|
||||
|
||||
class FakeService:
|
||||
async def create(
|
||||
self, *, owner_id: UUID, data: AutomationJobCreateRequest
|
||||
) -> AutomationJobResponse:
|
||||
raise AutomationJobLimitExceeded()
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/automation-jobs",
|
||||
json={
|
||||
"title": "New Job",
|
||||
"schedule_type": "daily",
|
||||
"run_at": "09:00:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"status": "active",
|
||||
"config": {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "maximum" in response.json()["detail"].lower()
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_get_automation_job_requires_auth() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/api/v1/automation-jobs/{uuid4()}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_get_automation_job_returns_job() -> None:
|
||||
user_id = uuid4()
|
||||
job_id = uuid4()
|
||||
job = _make_job_response(id=job_id, owner_id=user_id)
|
||||
|
||||
captured_job_id = job_id
|
||||
captured_owner_id = user_id
|
||||
|
||||
class FakeService:
|
||||
async def get_by_id(
|
||||
self, *, job_id: UUID, owner_id: UUID
|
||||
) -> AutomationJobResponse:
|
||||
if job_id == captured_job_id and owner_id == captured_owner_id:
|
||||
return job
|
||||
raise AutomationJobNotFound()
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.get(f"/api/v1/automation-jobs/{job_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == "Test Job"
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_get_automation_job_returns_404_when_not_found() -> None:
|
||||
user_id = uuid4()
|
||||
|
||||
class FakeService:
|
||||
async def get_by_id(
|
||||
self, *, job_id: UUID, owner_id: UUID
|
||||
) -> AutomationJobResponse:
|
||||
raise AutomationJobNotFound()
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.get(f"/api/v1/automation-jobs/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_update_automation_job_requires_auth() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.patch(
|
||||
f"/api/v1/automation-jobs/{uuid4()}",
|
||||
json={"title": "Updated"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_automation_job_succeeds() -> None:
|
||||
user_id = uuid4()
|
||||
job_id = uuid4()
|
||||
updated_job = _make_job_response(id=job_id, owner_id=user_id, title="Updated Title")
|
||||
|
||||
class FakeService:
|
||||
async def update(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
owner_id: UUID,
|
||||
data: AutomationJobUpdateRequest,
|
||||
) -> AutomationJobResponse:
|
||||
return updated_job
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.patch(
|
||||
f"/api/v1/automation-jobs/{job_id}",
|
||||
json={"title": "Updated Title"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == "Updated Title"
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_update_automation_job_returns_404_when_not_found() -> None:
|
||||
user_id = uuid4()
|
||||
|
||||
class FakeService:
|
||||
async def update(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
owner_id: UUID,
|
||||
data: AutomationJobUpdateRequest,
|
||||
) -> AutomationJobResponse:
|
||||
raise AutomationJobNotFound()
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.patch(
|
||||
f"/api/v1/automation-jobs/{uuid4()}", json={"title": "Updated"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_delete_automation_job_requires_auth() -> None:
|
||||
client = TestClient(app)
|
||||
response = client.delete(f"/api/v1/automation-jobs/{uuid4()}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_delete_automation_job_succeeds() -> None:
|
||||
user_id = uuid4()
|
||||
job_id = uuid4()
|
||||
|
||||
class FakeService:
|
||||
async def delete(self, *, job_id: UUID, owner_id: UUID) -> None:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.delete(f"/api/v1/automation-jobs/{job_id}")
|
||||
assert response.status_code == 204
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
|
||||
|
||||
def test_delete_automation_job_returns_404_when_not_found() -> None:
|
||||
user_id = uuid4()
|
||||
|
||||
class FakeService:
|
||||
async def delete(self, *, job_id: UUID, owner_id: UUID) -> None:
|
||||
raise AutomationJobNotFound()
|
||||
|
||||
app.dependency_overrides[get_automation_jobs_service] = lambda: FakeService()
|
||||
app.dependency_overrides[get_current_user] = lambda: CurrentUser(
|
||||
id=user_id, phone="+8613812345678"
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.delete(f"/api/v1/automation-jobs/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides = {}
|
||||
@@ -12,6 +12,10 @@ def test_memory_automation_static_config_contract() -> None:
|
||||
"memory.write",
|
||||
"memory.forget",
|
||||
]
|
||||
prompt = config.input_template
|
||||
assert "提取" in prompt
|
||||
assert "遗忘" in prompt
|
||||
assert config.input_template is not None
|
||||
assert "提取" in config.input_template
|
||||
assert "遗忘" in config.input_template
|
||||
assert config.schedule is not None
|
||||
assert config.schedule.type.value == "daily"
|
||||
assert config.schedule.run_at.hour == 8
|
||||
assert config.schedule.run_at.minute == 0
|
||||
|
||||
@@ -6,6 +6,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from models.automation_jobs import ScheduleType
|
||||
from v1.auth.registration_bootstrap import (
|
||||
compute_next_local_time_utc,
|
||||
)
|
||||
@@ -19,6 +20,7 @@ def test_compute_next_local_time_utc_from_asia_shanghai() -> None:
|
||||
timezone_name="Asia/Shanghai",
|
||||
local_hour=8,
|
||||
local_minute=0,
|
||||
schedule_type=ScheduleType.DAILY,
|
||||
)
|
||||
|
||||
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -33,6 +35,7 @@ def test_compute_next_local_time_utc_rolls_to_next_day_when_passed() -> None:
|
||||
timezone_name="Asia/Shanghai",
|
||||
local_hour=8,
|
||||
local_minute=0,
|
||||
schedule_type=ScheduleType.DAILY,
|
||||
)
|
||||
|
||||
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
@@ -1,283 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from models.automation_jobs import AutomationJobStatus, ScheduleType
|
||||
from v1.automation_jobs.repository import AutomationJobsRepository
|
||||
from v1.automation_jobs.schemas import (
|
||||
AutomationJobCreateRequest,
|
||||
AutomationJobUpdateRequest,
|
||||
)
|
||||
from schemas.automation import (
|
||||
AgentTool,
|
||||
AutomationJobConfig,
|
||||
ContextSource,
|
||||
ContextWindowMode,
|
||||
MessageContextConfig,
|
||||
)
|
||||
|
||||
|
||||
class _ExecuteResult:
|
||||
def __init__(self, value: object) -> None:
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self) -> object:
|
||||
return self._value
|
||||
|
||||
def scalar_one(self) -> int:
|
||||
return self._value # type: ignore[return-value]
|
||||
def _make_config() -> AutomationJobConfig:
|
||||
return AutomationJobConfig(
|
||||
input_template="Hello",
|
||||
enabled_tools=[AgentTool.MEMORY_WRITE],
|
||||
context=MessageContextConfig(
|
||||
source=ContextSource.LATEST_CHAT,
|
||||
window_mode=ContextWindowMode.DAY,
|
||||
window_count=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _ScalarRows:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class _ExecuteRowsResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self) -> _ScalarRows:
|
||||
return _ScalarRows(self._rows)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
self.flushed = False
|
||||
self._execute_result: object = None
|
||||
self._return_rows: bool = False
|
||||
|
||||
def set_execute_result(self, value: object) -> None:
|
||||
self._execute_result = value
|
||||
self._return_rows = isinstance(value, list)
|
||||
|
||||
async def execute(self, stmt): # noqa: ANN001
|
||||
del stmt
|
||||
if self._return_rows:
|
||||
return _ExecuteRowsResult(self._execute_result)
|
||||
return _ExecuteResult(self._execute_result)
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
async def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session() -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(fake_session: _FakeSession) -> AutomationJobsRepository:
|
||||
return AutomationJobsRepository(session=fake_session) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=uuid4(),
|
||||
owner_id=uuid4(),
|
||||
bootstrap_key=None,
|
||||
def _make_create_request() -> AutomationJobCreateRequest:
|
||||
return AutomationJobCreateRequest(
|
||||
title="Test Job",
|
||||
config={"input_template": "Hello {name}"},
|
||||
schedule_type=ScheduleType.DAILY,
|
||||
run_at=datetime(2026, 3, 23, 0, 0, tzinfo=timezone.utc),
|
||||
next_run_at=datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc),
|
||||
timezone="UTC",
|
||||
run_at=time(9, 0, 0),
|
||||
timezone="Asia/Shanghai",
|
||||
status=AutomationJobStatus.ACTIVE,
|
||||
created_by=uuid4(),
|
||||
deleted_at=None,
|
||||
config=_make_config(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_by_owner_returns_jobs(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
sample_job: SimpleNamespace,
|
||||
) -> None:
|
||||
fake_session.set_execute_result([sample_job])
|
||||
|
||||
async def test_list_by_owner_returns_jobs() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
owner_id = uuid4()
|
||||
jobs = await repository.list_by_owner(owner_id)
|
||||
job_one = MagicMock()
|
||||
job_two = MagicMock()
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalars.return_value.all.return_value = [job_one, job_two]
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].title == "Test Job"
|
||||
result = await repository.list_by_owner(owner_id)
|
||||
|
||||
assert result == [job_one, job_two]
|
||||
session.execute.assert_awaited_once()
|
||||
call_args = session.execute.call_args
|
||||
stmt = call_args[0][0]
|
||||
assert "owner_id" in str(stmt)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_by_owner_returns_empty_list(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
fake_session.set_execute_result([])
|
||||
|
||||
async def test_count_user_jobs_counts_non_bootstrap_jobs() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
owner_id = uuid4()
|
||||
jobs = await repository.list_by_owner(owner_id)
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one.return_value = 3
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
assert jobs == []
|
||||
result = await repository.count_user_jobs(owner_id)
|
||||
|
||||
assert result == 3
|
||||
session.execute.assert_awaited_once()
|
||||
call_args = session.execute.call_args
|
||||
stmt = call_args[0][0]
|
||||
stmt_str = str(stmt)
|
||||
assert "bootstrap_key" in stmt_str
|
||||
assert "IS NULL" in stmt_str or "is_(None)" in stmt_str.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_job(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
sample_job: SimpleNamespace,
|
||||
) -> None:
|
||||
fake_session.set_execute_result(sample_job)
|
||||
async def test_create_sets_bootstrap_key_to_none() -> None:
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
|
||||
await repository.create(owner_id, data)
|
||||
|
||||
session.add.assert_called_once()
|
||||
call_args = session.add.call_args[0][0]
|
||||
assert call_args.bootstrap_key is None
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_sets_correct_fields() -> None:
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
|
||||
await repository.create(owner_id, data)
|
||||
|
||||
call_args = session.add.call_args[0][0]
|
||||
assert call_args.owner_id == owner_id
|
||||
assert call_args.title == data.title
|
||||
assert call_args.schedule_type == data.schedule_type
|
||||
assert call_args.timezone == data.timezone
|
||||
assert call_args.status == data.status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_returns_updated_job() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
job = await repository.get_by_id(job_id)
|
||||
existing_job = MagicMock()
|
||||
existing_job.schedule_type = ScheduleType.DAILY
|
||||
existing_job.config = {"input_template": "Old"}
|
||||
updated_job = MagicMock()
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = updated_job
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
assert job is not None
|
||||
assert job.title == "Test Job"
|
||||
data = AutomationJobUpdateRequest(title="Updated Title")
|
||||
result = await repository.update(job_id, data)
|
||||
|
||||
assert result is updated_job
|
||||
session.flush.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_none_when_not_found(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
fake_session.set_execute_result(None)
|
||||
|
||||
async def test_update_merges_config() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
job = await repository.get_by_id(job_id)
|
||||
existing_job = MagicMock()
|
||||
existing_job.schedule_type = ScheduleType.DAILY
|
||||
existing_job.config = {"input_template": "Old", "enabled_tools": []}
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = existing_job
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
assert job is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_user_jobs_returns_count(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
fake_session.set_execute_result(5)
|
||||
|
||||
owner_id = uuid4()
|
||||
count = await repository.count_user_jobs(owner_id)
|
||||
|
||||
assert count == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_user_jobs_returns_zero_when_none(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
fake_session.set_execute_result(0)
|
||||
|
||||
owner_id = uuid4()
|
||||
count = await repository.count_user_jobs(owner_id)
|
||||
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
from v1.automation_jobs.schemas import AutomationJobCreateRequest
|
||||
from schemas.automation import AutomationJobConfig
|
||||
|
||||
owner_id = uuid4()
|
||||
request = AutomationJobCreateRequest(
|
||||
title="New Job",
|
||||
schedule_type=ScheduleType.DAILY,
|
||||
run_at=time(0, 0),
|
||||
timezone="UTC",
|
||||
status=AutomationJobStatus.ACTIVE,
|
||||
config=AutomationJobConfig(input_template="Test"),
|
||||
data = AutomationJobUpdateRequest(
|
||||
config={"input_template": "New", "context": {"source": "latest_chat"}}
|
||||
)
|
||||
await repository.update(job_id, data)
|
||||
|
||||
job = await repository.create(owner_id, request)
|
||||
|
||||
assert job.title == "New Job"
|
||||
assert job.owner_id == owner_id
|
||||
assert job.created_by == owner_id
|
||||
assert job.bootstrap_key is None
|
||||
assert job.schedule_type == ScheduleType.DAILY
|
||||
assert fake_session.flushed is True
|
||||
assert len(fake_session.added) == 1
|
||||
session.flush.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
async def test_update_returns_none_when_job_not_found() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = None
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
data = AutomationJobUpdateRequest(title="Updated Title")
|
||||
result = await repository.update(job_id, data)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_calls_soft_delete_by_id() -> None:
|
||||
session = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = None
|
||||
session.execute.return_value = execute_result
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
fake_session.set_execute_result(None)
|
||||
|
||||
await repository.soft_delete(job_id)
|
||||
|
||||
assert fake_session.flushed is True
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_job_title(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
sample_job: SimpleNamespace,
|
||||
) -> None:
|
||||
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||
async def test_list_due_jobs_filters_by_active_status() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalars.return_value.all.return_value = []
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
sample_job.title = "Updated Title"
|
||||
fake_session.set_execute_result(sample_job)
|
||||
await repository.list_due_jobs(now_utc=MagicMock(), limit=10)
|
||||
|
||||
request = AutomationJobUpdateRequest(title="Updated Title")
|
||||
job = await repository.update(sample_job.id, request)
|
||||
|
||||
assert job is not None
|
||||
assert job.title == "Updated Title"
|
||||
session.execute.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_job_run_at_recomputes_next_run_at(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
sample_job: SimpleNamespace,
|
||||
) -> None:
|
||||
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||
async def test_create_stores_run_at_as_timezone_aware() -> None:
|
||||
session = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
|
||||
fake_session.set_execute_result(sample_job)
|
||||
await repository.create(owner_id, data)
|
||||
|
||||
request = AutomationJobUpdateRequest(
|
||||
run_at=time(12, 0),
|
||||
timezone="UTC",
|
||||
call_args = session.add.call_args[0][0]
|
||||
assert call_args.run_at.tzinfo is not None, "run_at should be timezone-aware"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_run_at_with_timezone_none_uses_existing_timezone() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
existing_job = MagicMock()
|
||||
existing_job.schedule_type = ScheduleType.DAILY
|
||||
existing_job.timezone = "America/New_York"
|
||||
existing_job.config = {}
|
||||
existing_job.run_at = None
|
||||
execute_result = MagicMock()
|
||||
execute_result.scalar_one_or_none.return_value = existing_job
|
||||
session.execute.return_value = execute_result
|
||||
|
||||
repository.update_by_id = AsyncMock(return_value=existing_job)
|
||||
|
||||
data = AutomationJobUpdateRequest(run_at=time(14, 30, 0))
|
||||
result = await repository.update(job_id, data)
|
||||
|
||||
assert result is not None
|
||||
update_values = repository.update_by_id.call_args[0][1]
|
||||
assert "run_at" in update_values
|
||||
assert "next_run_at" in update_values
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_schedule_type_recomputes_next_run_at() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
existing_job = MagicMock()
|
||||
existing_job.schedule_type = ScheduleType.DAILY
|
||||
existing_job.timezone = "UTC"
|
||||
existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
|
||||
existing_job.config = {}
|
||||
|
||||
repository.get_by_id = AsyncMock(return_value=existing_job)
|
||||
repository.update_by_id = AsyncMock(return_value=existing_job)
|
||||
|
||||
data = AutomationJobUpdateRequest(schedule_type=ScheduleType.WEEKLY)
|
||||
result = await repository.update(job_id, data)
|
||||
|
||||
assert result is not None
|
||||
update_values = repository.update_by_id.call_args[0][1]
|
||||
assert update_values["schedule_type"] == ScheduleType.WEEKLY
|
||||
assert "run_at" in update_values
|
||||
assert "next_run_at" in update_values
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_serializes_enum_values_to_json() -> None:
|
||||
session = AsyncMock()
|
||||
repository = AutomationJobsRepository(session)
|
||||
job_id = uuid4()
|
||||
existing_job = MagicMock()
|
||||
existing_job.schedule_type = ScheduleType.DAILY
|
||||
existing_job.timezone = "UTC"
|
||||
existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
|
||||
existing_job.config = {"input_template": "Old"}
|
||||
|
||||
repository.get_by_id = AsyncMock(return_value=existing_job)
|
||||
repository.update_by_id = AsyncMock(return_value=existing_job)
|
||||
|
||||
data = AutomationJobUpdateRequest(
|
||||
config={"enabled_tools": [AgentTool.MEMORY_WRITE]},
|
||||
)
|
||||
job = await repository.update(sample_job.id, request)
|
||||
result = await repository.update(job_id, data)
|
||||
|
||||
assert job is not None
|
||||
assert fake_session.flushed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_returns_none_when_job_not_found(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
) -> None:
|
||||
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||
|
||||
fake_session.set_execute_result(None)
|
||||
|
||||
request = AutomationJobUpdateRequest(title="New Title")
|
||||
job = await repository.update(uuid4(), request)
|
||||
|
||||
assert job is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_no_changes_returns_existing_job(
|
||||
repository: AutomationJobsRepository,
|
||||
fake_session: _FakeSession,
|
||||
sample_job: SimpleNamespace,
|
||||
) -> None:
|
||||
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||
|
||||
fake_session.set_execute_result(sample_job)
|
||||
|
||||
request = AutomationJobUpdateRequest()
|
||||
job = await repository.update(sample_job.id, request)
|
||||
|
||||
assert job is not None
|
||||
assert job.title == "Test Job"
|
||||
assert result is not None
|
||||
update_values = repository.update_by_id.call_args[0][1]
|
||||
enabled_tools = update_values["config"]["enabled_tools"]
|
||||
assert isinstance(enabled_tools[0], str)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from v1.automation_jobs.schemas import (
|
||||
AutomationJobCreateRequest,
|
||||
AutomationJobUpdateRequest,
|
||||
AutomationJobResponse,
|
||||
)
|
||||
from schemas.automation import AgentTool, AutomationJobConfig
|
||||
|
||||
|
||||
class TestIsSystemProperty:
|
||||
def test_is_system_true_when_bootstrap_key_present(self):
|
||||
mock_orm_job = MagicMock()
|
||||
mock_orm_job.id = uuid4()
|
||||
mock_orm_job.owner_id = uuid4()
|
||||
mock_orm_job.bootstrap_key = "memory_extraction"
|
||||
mock_orm_job.title = "Test Job"
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.run_at = datetime.now()
|
||||
mock_orm_job.config = {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
}
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.status = "active"
|
||||
mock_orm_job.timezone = "Asia/Shanghai"
|
||||
mock_orm_job.next_run_at = datetime.now()
|
||||
mock_orm_job.last_run_at = None
|
||||
mock_orm_job.created_at = datetime.now()
|
||||
mock_orm_job.updated_at = datetime.now()
|
||||
mock_orm_job.deleted_at = None
|
||||
|
||||
resp = AutomationJobResponse.from_orm(mock_orm_job)
|
||||
assert resp.is_system is True
|
||||
|
||||
def test_is_system_false_when_bootstrap_key_none(self):
|
||||
mock_orm_job = MagicMock()
|
||||
mock_orm_job.id = uuid4()
|
||||
mock_orm_job.owner_id = uuid4()
|
||||
mock_orm_job.bootstrap_key = None
|
||||
mock_orm_job.title = "Test Job"
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.run_at = datetime.now()
|
||||
mock_orm_job.config = {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
}
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.status = "active"
|
||||
mock_orm_job.timezone = "Asia/Shanghai"
|
||||
mock_orm_job.next_run_at = datetime.now()
|
||||
mock_orm_job.last_run_at = None
|
||||
mock_orm_job.created_at = datetime.now()
|
||||
mock_orm_job.updated_at = datetime.now()
|
||||
mock_orm_job.deleted_at = None
|
||||
|
||||
resp = AutomationJobResponse.from_orm(mock_orm_job)
|
||||
assert resp.is_system is False
|
||||
|
||||
|
||||
class TestFromOrm:
|
||||
def test_run_at_converted_from_datetime_to_time(self):
|
||||
run_at_datetime = datetime(2024, 6, 15, 14, 30, 0)
|
||||
mock_orm_job = MagicMock()
|
||||
mock_orm_job.id = uuid4()
|
||||
mock_orm_job.owner_id = uuid4()
|
||||
mock_orm_job.bootstrap_key = None
|
||||
mock_orm_job.title = "Test Job"
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.run_at = run_at_datetime
|
||||
mock_orm_job.config = {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
}
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.status = "active"
|
||||
mock_orm_job.timezone = "Asia/Shanghai"
|
||||
mock_orm_job.next_run_at = datetime.now()
|
||||
mock_orm_job.last_run_at = None
|
||||
mock_orm_job.created_at = datetime.now()
|
||||
mock_orm_job.updated_at = datetime.now()
|
||||
mock_orm_job.deleted_at = None
|
||||
|
||||
resp = AutomationJobResponse.from_orm(mock_orm_job)
|
||||
assert resp.run_at == run_at_datetime.time()
|
||||
|
||||
def test_config_deserialized(self):
|
||||
config = {
|
||||
"input_template": "Test template",
|
||||
"enabled_tools": [AgentTool.MEMORY_WRITE],
|
||||
"context": {
|
||||
"source": "latest_chat",
|
||||
"window_mode": "day",
|
||||
"window_count": 5,
|
||||
},
|
||||
}
|
||||
mock_orm_job = MagicMock()
|
||||
mock_orm_job.id = uuid4()
|
||||
mock_orm_job.owner_id = uuid4()
|
||||
mock_orm_job.bootstrap_key = None
|
||||
mock_orm_job.title = "Test Job"
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.run_at = datetime.now()
|
||||
mock_orm_job.config = config
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.status = "active"
|
||||
mock_orm_job.timezone = "Asia/Shanghai"
|
||||
mock_orm_job.next_run_at = datetime.now()
|
||||
mock_orm_job.last_run_at = None
|
||||
mock_orm_job.created_at = datetime.now()
|
||||
mock_orm_job.updated_at = datetime.now()
|
||||
mock_orm_job.deleted_at = None
|
||||
|
||||
resp = AutomationJobResponse.from_orm(mock_orm_job)
|
||||
assert resp.config.input_template == "Test template"
|
||||
assert resp.config.enabled_tools == [AgentTool.MEMORY_WRITE]
|
||||
assert resp.config.context.window_count == 5
|
||||
|
||||
def test_is_system_derived_from_bootstrap_key(self):
|
||||
mock_orm_job = MagicMock()
|
||||
mock_orm_job.id = uuid4()
|
||||
mock_orm_job.owner_id = uuid4()
|
||||
mock_orm_job.bootstrap_key = "system_bootstrap"
|
||||
mock_orm_job.title = "Test Job"
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.run_at = datetime.now()
|
||||
mock_orm_job.config = {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {},
|
||||
}
|
||||
mock_orm_job.schedule_type = "daily"
|
||||
mock_orm_job.status = "active"
|
||||
mock_orm_job.timezone = "UTC"
|
||||
mock_orm_job.next_run_at = datetime.now()
|
||||
mock_orm_job.last_run_at = None
|
||||
mock_orm_job.created_at = datetime.now()
|
||||
mock_orm_job.updated_at = datetime.now()
|
||||
mock_orm_job.deleted_at = None
|
||||
|
||||
resp = AutomationJobResponse.from_orm(mock_orm_job)
|
||||
assert resp.is_system is True
|
||||
assert resp.bootstrap_key == "system_bootstrap"
|
||||
|
||||
|
||||
class TestTimezoneValidation:
|
||||
def test_valid_timezone(self):
|
||||
request = AutomationJobCreateRequest.model_validate(
|
||||
{
|
||||
"title": "Test Job",
|
||||
"schedule_type": "daily",
|
||||
"run_at": "09:00:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"config": {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {
|
||||
"source": "latest_chat",
|
||||
"window_mode": "day",
|
||||
"window_count": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert request.timezone == "Asia/Shanghai"
|
||||
|
||||
def test_invalid_timezone(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AutomationJobCreateRequest.model_validate(
|
||||
{
|
||||
"title": "Test Job",
|
||||
"schedule_type": "daily",
|
||||
"run_at": "09:00:00",
|
||||
"timezone": "Invalid/Timezone",
|
||||
"config": {
|
||||
"input_template": "Hello",
|
||||
"enabled_tools": [],
|
||||
"context": {
|
||||
"source": "latest_chat",
|
||||
"window_mode": "day",
|
||||
"window_count": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "timezone must be a valid IANA timezone" in str(exc_info.value)
|
||||
|
||||
def test_update_valid_timezone(self):
|
||||
request = AutomationJobUpdateRequest.model_validate(
|
||||
{
|
||||
"timezone": "America/New_York",
|
||||
}
|
||||
)
|
||||
assert request.timezone == "America/New_York"
|
||||
|
||||
def test_update_invalid_timezone(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AutomationJobUpdateRequest.model_validate(
|
||||
{
|
||||
"timezone": "Invalid/Timezone",
|
||||
}
|
||||
)
|
||||
assert "timezone must be a valid IANA timezone" in str(exc_info.value)
|
||||
|
||||
def test_update_none_timezone_allowed(self):
|
||||
request = AutomationJobUpdateRequest.model_validate(
|
||||
{
|
||||
"timezone": None,
|
||||
}
|
||||
)
|
||||
assert request.timezone is None
|
||||
|
||||
|
||||
class TestAutomationJobConfigPatch:
|
||||
def test_all_fields_optional(self):
|
||||
patch = AutomationJobConfig.model_validate({})
|
||||
assert patch.input_template is None
|
||||
assert patch.enabled_tools is None
|
||||
assert patch.context is None
|
||||
|
||||
def test_partial_input_template(self):
|
||||
patch = AutomationJobConfig.model_validate(
|
||||
{
|
||||
"input_template": "Updated template",
|
||||
}
|
||||
)
|
||||
assert patch.input_template == "Updated template"
|
||||
assert patch.enabled_tools is None
|
||||
assert patch.context is None
|
||||
|
||||
def test_extra_fields_forbidden(self):
|
||||
with pytest.raises(ValidationError):
|
||||
AutomationJobConfig.model_validate(
|
||||
{
|
||||
"input_template": "Test",
|
||||
"unknown_field": "value",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,371 @@
|
||||
from datetime import datetime, time, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from models.automation_jobs import AutomationJobStatus, ScheduleType
|
||||
from v1.automation_jobs.service import (
|
||||
AutomationJobLimitExceeded,
|
||||
AutomationJobNotFound,
|
||||
AutomationJobsService,
|
||||
SystemJobModificationForbidden,
|
||||
)
|
||||
from v1.automation_jobs.schemas import (
|
||||
AutomationJobCreateRequest,
|
||||
AutomationJobUpdateRequest,
|
||||
)
|
||||
from schemas.automation import (
|
||||
AgentTool,
|
||||
AutomationJobConfig,
|
||||
ContextSource,
|
||||
ContextWindowMode,
|
||||
MessageContextConfig,
|
||||
)
|
||||
|
||||
|
||||
def _make_config() -> AutomationJobConfig:
|
||||
return AutomationJobConfig(
|
||||
input_template="Hello",
|
||||
enabled_tools=[AgentTool.MEMORY_WRITE],
|
||||
context=MessageContextConfig(
|
||||
source=ContextSource.LATEST_CHAT,
|
||||
window_mode=ContextWindowMode.DAY,
|
||||
window_count=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_create_request() -> AutomationJobCreateRequest:
|
||||
return AutomationJobCreateRequest(
|
||||
title="Test Job",
|
||||
schedule_type=ScheduleType.DAILY,
|
||||
run_at=time(9, 0, 0),
|
||||
timezone="Asia/Shanghai",
|
||||
status=AutomationJobStatus.ACTIVE,
|
||||
config=_make_config(),
|
||||
)
|
||||
|
||||
|
||||
def _make_job(
|
||||
owner_id: MagicMock | None = None, bootstrap_key: str | None = None
|
||||
) -> MagicMock:
|
||||
job = MagicMock()
|
||||
job.id = uuid4()
|
||||
job.owner_id = owner_id or uuid4()
|
||||
job.bootstrap_key = bootstrap_key
|
||||
job.title = "Test Job"
|
||||
job.schedule_type = ScheduleType.DAILY
|
||||
job.run_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
job.timezone = "Asia/Shanghai"
|
||||
job.status = AutomationJobStatus.ACTIVE
|
||||
job.config = {"input_template": "Hello"}
|
||||
job.next_run_at = datetime(2024, 1, 2, 9, 0, 0, tzinfo=timezone.utc)
|
||||
job.last_run_at = None
|
||||
job.created_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
job.updated_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
|
||||
return job
|
||||
|
||||
|
||||
class TestListByOwner:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_by_owner_returns_jobs(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id)
|
||||
repository.list_by_owner.return_value = [job]
|
||||
|
||||
result = await service.list_by_owner(owner_id)
|
||||
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].title == job.title
|
||||
repository.list_by_owner.assert_awaited_once_with(owner_id)
|
||||
|
||||
|
||||
class TestGetById:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_job(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id)
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
result = await service.get_by_id(job.id, owner_id)
|
||||
|
||||
assert result.title == job.title
|
||||
repository.get_by_id.assert_awaited_once_with(job.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_raises_not_found_when_job_none(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job_id = uuid4()
|
||||
repository.get_by_id.return_value = None
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.get_by_id(job_id, owner_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_raises_not_found_when_owner_mismatch(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
different_owner_id = uuid4()
|
||||
job = _make_job(different_owner_id)
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.get_by_id(job.id, owner_id)
|
||||
|
||||
|
||||
class TestCreate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_raises_limit_exceeded(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
repository.count_user_jobs.return_value = 3
|
||||
|
||||
with pytest.raises(AutomationJobLimitExceeded):
|
||||
await service.create(owner_id, data)
|
||||
|
||||
session.execute.assert_awaited_once()
|
||||
session.rollback.assert_awaited_once()
|
||||
repository.count_user_jobs.assert_awaited_once_with(owner_id)
|
||||
repository.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_succeeds_when_under_limit(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
job = _make_job(owner_id)
|
||||
repository.count_user_jobs.return_value = 2
|
||||
repository.create.return_value = job
|
||||
|
||||
result = await service.create(owner_id, data)
|
||||
|
||||
assert result.title == job.title
|
||||
session.execute.assert_awaited_once()
|
||||
repository.create.assert_awaited_once_with(owner_id, data)
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_commits_session(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
job = _make_job(owner_id)
|
||||
repository.count_user_jobs.return_value = 0
|
||||
repository.create.return_value = job
|
||||
|
||||
await service.create(owner_id, data)
|
||||
|
||||
session.execute.assert_awaited_once()
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rollbacks_on_sqlalchemy_error(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
data = _make_create_request()
|
||||
repository.count_user_jobs.return_value = 0
|
||||
repository.create.side_effect = SQLAlchemyError("db down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await service.create(owner_id, data)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
session.execute.assert_awaited_once()
|
||||
session.rollback.assert_awaited_once()
|
||||
session.commit.assert_not_awaited()
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_raises_not_found_when_job_none(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job_id = uuid4()
|
||||
repository.get_by_id.return_value = None
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.update(
|
||||
job_id, owner_id, AutomationJobUpdateRequest(title="New")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_raises_not_found_when_owner_mismatch(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
different_owner_id = uuid4()
|
||||
job = _make_job(different_owner_id)
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.update(
|
||||
job.id, owner_id, AutomationJobUpdateRequest(title="New")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_raises_system_job_forbidden(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id, bootstrap_key="system-key")
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
with pytest.raises(SystemJobModificationForbidden):
|
||||
await service.update(
|
||||
job.id, owner_id, AutomationJobUpdateRequest(title="New")
|
||||
)
|
||||
|
||||
repository.update.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_succeeds(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id)
|
||||
updated_job = _make_job(owner_id)
|
||||
updated_job.title = "Updated Title"
|
||||
repository.get_by_id.return_value = job
|
||||
repository.update.return_value = updated_job
|
||||
|
||||
result = await service.update(
|
||||
job.id, owner_id, AutomationJobUpdateRequest(title="Updated Title")
|
||||
)
|
||||
|
||||
assert result.title == "Updated Title"
|
||||
repository.update.assert_awaited_once_with(
|
||||
job.id, AutomationJobUpdateRequest(title="Updated Title")
|
||||
)
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_returns_not_found_when_update_returns_none(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id)
|
||||
repository.get_by_id.return_value = job
|
||||
repository.update.return_value = None
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.update(
|
||||
job.id, owner_id, AutomationJobUpdateRequest(title="New")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rollbacks_on_sqlalchemy_error(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id, bootstrap_key=None)
|
||||
repository.get_by_id.return_value = job
|
||||
repository.update.side_effect = SQLAlchemyError("db down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await service.update(
|
||||
job.id, owner_id, AutomationJobUpdateRequest(title="New")
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
session.rollback.assert_awaited_once()
|
||||
|
||||
|
||||
class TestDelete:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_raises_not_found_when_job_none(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job_id = uuid4()
|
||||
repository.get_by_id.return_value = None
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.delete(job_id, owner_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_raises_not_found_when_owner_mismatch(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
different_owner_id = uuid4()
|
||||
job = _make_job(different_owner_id)
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
with pytest.raises(AutomationJobNotFound):
|
||||
await service.delete(job.id, owner_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_raises_system_job_forbidden(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id, bootstrap_key="system-key")
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
with pytest.raises(SystemJobModificationForbidden):
|
||||
await service.delete(job.id, owner_id)
|
||||
|
||||
repository.soft_delete.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_succeeds(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id)
|
||||
repository.get_by_id.return_value = job
|
||||
|
||||
await service.delete(job.id, owner_id)
|
||||
|
||||
repository.soft_delete.assert_awaited_once_with(job.id)
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rollbacks_on_sqlalchemy_error(self) -> None:
|
||||
session = AsyncMock()
|
||||
repository = AsyncMock()
|
||||
service = AutomationJobsService(repository, session)
|
||||
owner_id = uuid4()
|
||||
job = _make_job(owner_id, bootstrap_key=None)
|
||||
repository.get_by_id.return_value = job
|
||||
repository.soft_delete.side_effect = SQLAlchemyError("db down")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await service.delete(job.id, owner_id)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
session.rollback.assert_awaited_once()
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
- SOCIAL_REDIS__HOST=redis
|
||||
- SOCIAL_REDIS__PORT=6379
|
||||
command: >
|
||||
sh -c 'uv run uvicorn app:app --host ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${SOCIAL_WEB__PORT:-5775} --workers ${SOCIAL_WEB__WORKERS:-2} --log-level $(printf "%s" "${SOCIAL_RUNTIME__LOG_LEVEL:-info}" | tr "[:upper:]" "[:lower:]")'
|
||||
sh -c '.venv/bin/uvicorn app:app --host ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${SOCIAL_WEB__PORT:-5775} --workers ${SOCIAL_WEB__WORKERS:-2} --log-level $(printf "%s" "${SOCIAL_RUNTIME__LOG_LEVEL:-info}" | tr "[:upper:]" "[:lower:]")'
|
||||
ports:
|
||||
- "127.0.0.1:${SOCIAL_WEB__PORT:-5775}:${SOCIAL_WEB__PORT:-5775}"
|
||||
depends_on:
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
- SOCIAL_REDIS__HOST=redis
|
||||
- SOCIAL_REDIS__PORT=6379
|
||||
command: >
|
||||
sh -c 'uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY:-2}'
|
||||
sh -c '.venv/bin/taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AGENT__CONCURRENCY:-2}'
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -94,7 +94,7 @@ services:
|
||||
- SOCIAL_REDIS__HOST=redis
|
||||
- SOCIAL_REDIS__PORT=6379
|
||||
command: >
|
||||
sh -c 'uv run taskiq worker core.taskiq.app:worker_automation_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY:-1}'
|
||||
sh -c '.venv/bin/taskiq worker core.taskiq.app:worker_automation_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__AUTOMATION__CONCURRENCY:-1}'
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -115,7 +115,7 @@ services:
|
||||
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
|
||||
- SOCIAL_REDIS__HOST=redis
|
||||
- SOCIAL_REDIS__PORT=6379
|
||||
command: uv run python -m core.runtime.cli automation-scheduler
|
||||
command: .venv/bin/python -m core.runtime.cli automation-scheduler
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -136,7 +136,7 @@ services:
|
||||
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
|
||||
- SOCIAL_REDIS__HOST=redis
|
||||
- SOCIAL_REDIS__PORT=6379
|
||||
command: uv run python -m core.runtime.cli bootstrap
|
||||
command: .venv/bin/python -m core.runtime.cli bootstrap
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Bug: 前端未渲染 events 接口事件
|
||||
|
||||
## 日期
|
||||
|
||||
- 2026-03-24
|
||||
|
||||
## 现象
|
||||
|
||||
- 用户反馈:改动后前端无法获取/渲染 `/api/v1/agent/runs/{threadId}/events` 的事件。
|
||||
- 页面表现为消息流无事件增量或工具执行状态未更新。
|
||||
|
||||
## 本次背景
|
||||
|
||||
- 本次清理了前端死链路:
|
||||
- `ToolRegistry`
|
||||
- `RouteNavigationTool`
|
||||
- `AiDecisionEngine`
|
||||
- 当前主链路仍为 AG-UI SSE:`AgUiService -> AgUiEvent -> ChatBloc -> HomeChatItemRenderer`。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- Chat 事件流渲染(运行状态、工具调用状态、文本完成事件)
|
||||
- 可能影响 Home 聊天视图实时反馈
|
||||
|
||||
## 初步判断
|
||||
|
||||
- 已清理的死链路不在当前主流程中,理论上不应直接导致 SSE 事件无法渲染。
|
||||
- 更可能的问题点:
|
||||
1. `runId` 绑定过滤导致事件被丢弃(`shouldDispatch` 为 false)
|
||||
2. `onEvent` 回调异常导致流提前停止
|
||||
3. SSE `data` 结构变化,`AgUiEvent.fromJson` 解析失败
|
||||
|
||||
## 关键代码位置
|
||||
|
||||
- `apps/lib/features/chat/data/services/ag_ui_service.dart`
|
||||
- `apps/lib/features/chat/data/models/ag_ui_event.dart`
|
||||
- `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
|
||||
- `apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart`
|
||||
|
||||
## 待执行排查
|
||||
|
||||
1. 在 `_streamEventsFromApi` 增加临时诊断日志:`eventType`、`eventRunId`、`expectedRunId`、`shouldDispatch`
|
||||
2. 捕获并输出 `onEvent` 抛错栈,确认是否由 UI/Bloc 处理异常中断
|
||||
3. 抓取真实 SSE 帧,核对 `runId/threadId/type/data` 与解析模型一致性
|
||||
4. 复测 `RUN_STARTED -> TOOL_* -> TEXT_MESSAGE_END -> RUN_FINISHED/RUN_ERROR` 完整链路
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 状态:待定位
|
||||
- 优先级:高
|
||||
@@ -171,6 +171,35 @@ data: <json>
|
||||
- `tool_call_args` 仅表示输入参数快照。
|
||||
- `result` 仅表示执行输出事实,不重复 `tool_call_args` 已包含的输入参数。
|
||||
|
||||
#### 3.3.1 tool 名称展示规范(前端本地化)
|
||||
|
||||
SSE 协议中的工具名字段保持后端原样,不做服务端翻译:
|
||||
|
||||
- `TOOL_CALL_START/ARGS/END.toolCallName`
|
||||
- `TOOL_CALL_RESULT.tool_name`
|
||||
|
||||
前端展示层统一通过工具名本地化映射进行中文渲染,要求兼容两类命名风格:
|
||||
|
||||
- dot 风格:`memory.write`、`calendar.read`
|
||||
- snake 风格:`memory_write`、`calendar_read`
|
||||
|
||||
当前规范映射(canonical -> 中文)如下:
|
||||
|
||||
- `calendar.read` -> `读取日程`
|
||||
- `calendar.write` -> `写入日程`
|
||||
- `calendar.share` -> `共享日程`
|
||||
- `user.lookup` -> `查找联系人`
|
||||
- `memory.write` -> `写入记忆`
|
||||
- `memory.forget` -> `清理记忆`
|
||||
|
||||
兼容策略:
|
||||
|
||||
1. 优先按 alias 归一化(例如 `memory_write` -> `memory.write`)
|
||||
2. 命中 canonical 映射后展示中文
|
||||
3. 未命中时回退显示原始工具名(保证向后兼容)
|
||||
|
||||
该规范只约束展示,不改变 wire event 字段定义与取值。
|
||||
|
||||
### 3.4 文本完成事件
|
||||
|
||||
#### `TEXT_MESSAGE_END`
|
||||
|
||||
Reference in New Issue
Block a user