feat: 添加自动化任务(automation_jobs)功能模块

This commit is contained in:
qzl
2026-03-24 12:38:11 +08:00
parent f4b7eb7e09
commit 23359c2d01
43 changed files with 4266 additions and 1139 deletions
+4
View File
@@ -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);
+10
View File
@@ -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(),
+2
View File
@@ -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;
}
+29 -26
View File
@@ -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
+23 -6
View File
@@ -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
+18 -10
View File
@@ -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
+161 -138
View File
@@ -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)
+68
View File
@@ -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)
+23 -4
View File
@@ -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]
+127 -4
View File
@@ -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",
)
+2
View File
@@ -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()
+5 -5
View File
@@ -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` 完整链路
## 当前状态
- 状态:待定位
- 优先级:高
+29
View File
@@ -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`