From 23359c2d0113de9a42f12abd708a595e410d9038 Mon Sep 17 00:00:00 2001 From: qzl Date: Tue, 24 Mar 2026 12:38:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96=E4=BB=BB=E5=8A=A1(automation=5Fjobs)=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/lib/core/di/injection.dart | 4 + apps/lib/core/router/app_router.dart | 10 + apps/lib/core/router/app_routes.dart | 2 + .../chat/data/ai/ai_decision_engine.dart | 110 --- .../data/tools/route_navigation_tool.dart | 75 -- .../chat/data/tools/tool_registry.dart | 106 --- .../ui/widgets/home_chat_item_renderer.dart | 3 +- .../data/models/automation_job_model.dart | 417 ++++++++++ .../data/services/automation_jobs_api.dart | 38 + .../cubits/automation_jobs_cubit.dart | 84 ++ .../presentation/cubits/job_detail_cubit.dart | 87 ++ .../settings/ui/screens/features_screen.dart | 297 ++++--- .../ui/screens/job_detail_screen.dart | 783 ++++++++++++++++++ .../settings/ui/screens/settings_screen.dart | 33 +- .../lib/shared/utils/tool_name_localizer.dart | 35 + .../lib/shared/widgets/app_toggle_switch.dart | 55 +- .../chat/ai_decision_engine_test.dart | 173 ---- .../features/chat/tool_registry_test.dart | 101 --- .../widgets/home_chat_item_renderer_test.dart | 42 + .../models/automation_job_model_test.dart | 297 +++++++ .../cubits/automation_jobs_cubit_test.dart | 165 ++++ .../cubits/job_detail_cubit_test.dart | 238 ++++++ .../utils/tool_name_localizer_test.dart | 20 + .../static/automation/memory_extraction.yaml | 5 + .../config/static/route/frontend_routes.yaml | 51 +- backend/src/schemas/automation/__init__.py | 29 +- .../src/v1/auth/automation_static_config.py | 10 +- backend/src/v1/auth/registration_bootstrap.py | 28 +- .../src/v1/automation_jobs/dependencies.py | 34 + backend/src/v1/automation_jobs/repository.py | 299 ++++--- backend/src/v1/automation_jobs/router.py | 68 ++ backend/src/v1/automation_jobs/schemas.py | 27 +- backend/src/v1/automation_jobs/service.py | 131 ++- backend/src/v1/router.py | 2 + .../v1/automation_jobs/test_router.py | 371 +++++++++ .../test_memory_automation_static_config.py | 10 +- .../test_registration_bootstrap_service.py | 3 + .../v1/automation_jobs/test_repository.py | 456 +++++----- .../unit/v1/automation_jobs/test_schemas.py | 246 ++++++ .../unit/v1/automation_jobs/test_service.py | 371 +++++++++ deploy/docker-compose.prod.yml | 10 +- docs/bugs/2026-03-24-events-not-rendering.md | 50 ++ docs/protocols/agent/sse-events.md | 29 + 43 files changed, 4266 insertions(+), 1139 deletions(-) delete mode 100644 apps/lib/features/chat/data/ai/ai_decision_engine.dart delete mode 100644 apps/lib/features/chat/data/tools/route_navigation_tool.dart delete mode 100644 apps/lib/features/chat/data/tools/tool_registry.dart create mode 100644 apps/lib/features/settings/data/models/automation_job_model.dart create mode 100644 apps/lib/features/settings/data/services/automation_jobs_api.dart create mode 100644 apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart create mode 100644 apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart create mode 100644 apps/lib/features/settings/ui/screens/job_detail_screen.dart create mode 100644 apps/lib/shared/utils/tool_name_localizer.dart delete mode 100644 apps/test/features/chat/ai_decision_engine_test.dart delete mode 100644 apps/test/features/chat/tool_registry_test.dart create mode 100644 apps/test/features/home/ui/widgets/home_chat_item_renderer_test.dart create mode 100644 apps/test/features/settings/data/models/automation_job_model_test.dart create mode 100644 apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart create mode 100644 apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart create mode 100644 apps/test/shared/utils/tool_name_localizer_test.dart create mode 100644 backend/src/v1/automation_jobs/dependencies.py create mode 100644 backend/src/v1/automation_jobs/router.py create mode 100644 backend/tests/integration/v1/automation_jobs/test_router.py create mode 100644 backend/tests/unit/v1/automation_jobs/test_schemas.py create mode 100644 backend/tests/unit/v1/automation_jobs/test_service.py create mode 100644 docs/bugs/2026-03-24-events-not-rendering.md diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 6d9c852..950cf60 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -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 configureDependencies() async { final settingsApi = SettingsApi(apiClient); sl.registerSingleton(settingsApi); + final automationJobsApi = AutomationJobsApi(apiClient); + sl.registerSingleton(automationJobsApi); + final memoryService = MemoryService(apiClient); sl.registerSingleton(memoryService); diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 9088234..70f1071 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -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(), diff --git a/apps/lib/core/router/app_routes.dart b/apps/lib/core/router/app_routes.dart index 73fd80b..b676581 100644 --- a/apps/lib/core/router/app_routes.dart +++ b/apps/lib/core/router/app_routes.dart @@ -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'; diff --git a/apps/lib/features/chat/data/ai/ai_decision_engine.dart b/apps/lib/features/chat/data/ai/ai_decision_engine.dart deleted file mode 100644 index ed39476..0000000 --- a/apps/lib/features/chat/data/ai/ai_decision_engine.dart +++ /dev/null @@ -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? tryExtractEventArgs(String text) { - if (matchIntent(text) != Intent.createEvent) return null; - - final args = {}; - - 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? 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? args; - if (argsJson != null) { - try { - args = jsonDecode(argsJson) as Map; - } catch (_) { - args = {}; - } - } - - return ForceTriggerResult(toolName: toolName, args: args ?? {}); - } -} - -class ForceTriggerResult { - final String toolName; - final Map args; - ForceTriggerResult({required this.toolName, required this.args}); -} diff --git a/apps/lib/features/chat/data/tools/route_navigation_tool.dart b/apps/lib/features/chat/data/tools/route_navigation_tool.dart deleted file mode 100644 index 615447b..0000000 --- a/apps/lib/features/chat/data/tools/route_navigation_tool.dart +++ /dev/null @@ -1,75 +0,0 @@ -import '../../../../core/router/app_routes.dart'; - -typedef RouteNavigator = void Function(String target, {bool replace}); - -const Set _allowedRoutes = { - AppRoutes.settingsMain, - AppRoutes.todoList, - AppRoutes.todoCreate, - AppRoutes.calendarDayWeek, - AppRoutes.calendarMonth, - AppRoutes.calendarEventCreate, - AppRoutes.messageInviteList, - AppRoutes.contactsList, - AppRoutes.contactsAdd, -}; - -const List _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 execute(Map 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; - } -} diff --git a/apps/lib/features/chat/data/tools/tool_registry.dart b/apps/lib/features/chat/data/tools/tool_registry.dart deleted file mode 100644 index 7a566ca..0000000 --- a/apps/lib/features/chat/data/tools/tool_registry.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'route_navigation_tool.dart'; - -typedef ToolHandler = - Future> Function(Map args); - -/// 工具常量 -const _toolNameNavigateRoute = 'front.navigate_to_route'; - -class ToolDefinition { - final String name; - final String description; - final Map parameters; - final ToolHandler handler; - - ToolDefinition({ - required this.name, - required this.description, - required this.parameters, - required this.handler, - }); -} - -class ToolRegistry { - static final Map _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> _handleNavigateRoute( - Map args, - ) async { - return RouteNavigationTool.instance.execute(args); - } - - static ToolDefinition? getTool(String name) => _tools[name]; - static List getAllTools() => _tools.values.toList(); - - static Future> execute( - String toolName, - Map 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 args, - ) { - final tool = _tools[toolName]; - if (tool == null) { - return ToolValidationResult( - ok: false, - error: 'Tool not found: $toolName', - ); - } - - final required = tool.parameters['required'] as List? ?? []; - final missing = []; - 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}); -} diff --git a/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart b/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart index 75a6599..4a599ad 100644 --- a/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart +++ b/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart @@ -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, diff --git a/apps/lib/features/settings/data/models/automation_job_model.dart b/apps/lib/features/settings/data/models/automation_job_model.dart new file mode 100644 index 0000000..6345614 --- /dev/null +++ b/apps/lib/features/settings/data/models/automation_job_model.dart @@ -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 _parseStringList(dynamic v, {required String field}) { + if (v == null) { + return const []; + } + if (v is List && v.every((e) => e is String)) { + return v.cast(); + } + 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? 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 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 enabledTools; + final MessageContextConfigModel context; + + AutomationJobConfigModel({ + required this.inputTemplate, + required this.enabledTools, + required this.context, + }); + + factory AutomationJobConfigModel.fromJson(Map? 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?, + ) + : MessageContextConfigModel.fromJson(null), + ); + } + + Map toJson() => { + 'input_template': inputTemplate, + 'enabled_tools': enabledTools, + 'context': context.toJson(), + }; + + AutomationJobConfigModel copyWith({ + String? inputTemplate, + List? 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 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?, + ) + : 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 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 items; + + AutomationJobListResponse({required this.items}); + + factory AutomationJobListResponse.fromJson(Map? 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)) + .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 toJson() => { + 'title': title, + 'schedule_type': scheduleType, + 'run_at': runAt, + 'timezone': timezone, + 'status': status, + 'config': config.toJson(), + }; +} + +class AutomationJobConfigPatchModel { + final String? inputTemplate; + final List? enabledTools; + final MessageContextConfigModel? context; + + AutomationJobConfigPatchModel({ + this.inputTemplate, + this.enabledTools, + this.context, + }); + + Map toJson() { + final map = {}; + 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 toJson() { + final map = {}; + 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; + } +} diff --git a/apps/lib/features/settings/data/services/automation_jobs_api.dart b/apps/lib/features/settings/data/services/automation_jobs_api.dart new file mode 100644 index 0000000..590e58e --- /dev/null +++ b/apps/lib/features/settings/data/services/automation_jobs_api.dart @@ -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() async { + final response = await _client.get(_prefix); + final parsed = AutomationJobListResponse.fromJson(response.data); + return parsed.items; + } + + Future get(String id) async { + final response = await _client.get('$_prefix/$id'); + return AutomationJobModel.fromJson(response.data); + } + + Future create(AutomationJobCreateRequest request) async { + final response = await _client.post(_prefix, data: request.toJson()); + return AutomationJobModel.fromJson(response.data); + } + + Future 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 delete(String id) async { + await _client.delete('$_prefix/$id'); + } +} diff --git a/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart b/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart new file mode 100644 index 0000000..ed45fb3 --- /dev/null +++ b/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart @@ -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 jobs; + final bool isLoading; + final String? error; + + const AutomationJobsState({ + this.jobs = const [], + this.isLoading = false, + this.error, + }); + + List get systemJobs => + jobs.where((j) => j.isSystem).toList(); + List get userJobs => + jobs.where((j) => !j.isSystem).toList(); + List get dailyJobs => + jobs.where((j) => j.isDaily).toList(); + List get weeklyJobs => + jobs.where((j) => j.isWeekly).toList(); + bool get canCreateMore => userJobs.length < 3; + + AutomationJobsState copyWith({ + List? jobs, + bool? isLoading, + String? error, + }) { + return AutomationJobsState( + jobs: jobs ?? this.jobs, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } + + @override + List get props => [jobs, isLoading, error]; +} + +class AutomationJobsCubit extends Cubit { + final AutomationJobsApi _api; + + AutomationJobsCubit(this._api) : super(AutomationJobsState()); + + Future 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 deleteJob(String id) async { + try { + await _api.delete(id); + await loadJobs(); + } catch (e) { + emit(state.copyWith(error: e.toString())); + } + } + + Future 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())); + } + } +} diff --git a/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart b/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart new file mode 100644 index 0000000..3b90c88 --- /dev/null +++ b/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart @@ -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 get props => [job, isLoading, isSaving, error]; +} + +class JobDetailCubit extends Cubit { + final AutomationJobsApi _api; + + JobDetailCubit(this._api) : super(JobDetailState()); + + Future 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 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 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 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; + } + } +} diff --git a/apps/lib/features/settings/ui/screens/features_screen.dart b/apps/lib/features/settings/ui/screens/features_screen.dart index c265d2d..fe6dea2 100644 --- a/apps/lib/features/settings/ui/screens/features_screen.dart +++ b/apps/lib/features/settings/ui/screens/features_screen.dart @@ -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 { - bool _dailyReminderEnabled = true; - bool _dailySummaryEnabled = false; - bool _weeklyReportEnabled = true; - bool _weeklyDigestEnabled = false; + late final AutomationJobsCubit _cubit; + + @override + void initState() { + super.initState(); + _cubit = AutomationJobsCubit(sl()); + _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( + 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 { ); } - 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 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(); + }, ); } } diff --git a/apps/lib/features/settings/ui/screens/job_detail_screen.dart b/apps/lib/features/settings/ui/screens/job_detail_screen.dart new file mode 100644 index 0000000..b6ed28f --- /dev/null +++ b/apps/lib/features/settings/ui/screens/job_detail_screen.dart @@ -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 createState() => _JobDetailScreenState(); +} + +enum _JobDetailHeaderAction { delete } + +class _JobDetailScreenState extends State { + 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 _selectedTools = {'memory.write', 'memory.forget'}; + + @override + void initState() { + super.initState(); + _cubit = JobDetailCubit(sl()); + 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( + 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 _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 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 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 _pickScheduleType() async { + final picked = await showAppSelectionSheet( + context, + title: '选择周期', + selectedValue: _scheduleType, + items: const [ + AppSelectionItem(value: 'daily', label: '每日'), + AppSelectionItem(value: 'weekly', label: '每周'), + ], + ); + if (picked != null) { + setState(() { + _scheduleType = picked; + }); + } + } + + Future _pickTimezone() async { + final picked = await showAppSelectionSheet( + 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 _pickContextSource() async { + final picked = await showAppSelectionSheet( + context, + title: '选择上下文来源', + selectedValue: _contextSource, + items: const [AppSelectionItem(value: 'latest_chat', label: '最近聊天')], + ); + if (picked != null) { + setState(() { + _contextSource = picked; + }); + } + } + + Future _pickWindowMode() async { + final picked = await showAppSelectionSheet( + context, + title: '选择窗口模式', + selectedValue: _contextWindowMode, + items: const [ + AppSelectionItem(value: 'day', label: '按天数'), + AppSelectionItem(value: 'number', label: '按消息数'), + ], + ); + if (picked != null) { + setState(() { + _contextWindowMode = picked; + }); + } + } + + Future _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 _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); + } + } +} diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index 7f9274c..c0435f9 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -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 { final FriendsApi _friendsApi = sl(); + final AutomationJobsApi _automationJobsApi = sl(); final SettingsUserCache _userCache = sl(); 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 { } 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 { 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 { Expanded( child: _buildActionCard( icon: Icons.auto_awesome, - iconColor: AppColors.violet500, + iconColor: AppColors.blue500, title: '周期计划', - subtitle: '已启用:会议提醒', + subtitle: _buildAutomationSubtitle(), onTap: () => context.push(AppRoutes.settingsFeatures), ), ), diff --git a/apps/lib/shared/utils/tool_name_localizer.dart b/apps/lib/shared/utils/tool_name_localizer.dart new file mode 100644 index 0000000..9238565 --- /dev/null +++ b/apps/lib/shared/utils/tool_name_localizer.dart @@ -0,0 +1,35 @@ +const Map _toolNameZhMap = { + 'calendar.read': '读取日程', + 'calendar.write': '写入日程', + 'calendar.share': '共享日程', + 'user.lookup': '查找联系人', + 'memory.write': '写入记忆', + 'memory.forget': '清理记忆', +}; + +const Map _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 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; +} diff --git a/apps/lib/shared/widgets/app_toggle_switch.dart b/apps/lib/shared/widgets/app_toggle_switch.dart index a7aae5f..a2c023e 100644 --- a/apps/lib/shared/widgets/app_toggle_switch.dart +++ b/apps/lib/shared/widgets/app_toggle_switch.dart @@ -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 onChanged; + final ValueChanged? 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), + ), ), ), ), diff --git a/apps/test/features/chat/ai_decision_engine_test.dart b/apps/test/features/chat/ai_decision_engine_test.dart deleted file mode 100644 index 0bce7e4..0000000 --- a/apps/test/features/chat/ai_decision_engine_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/apps/test/features/chat/tool_registry_test.dart b/apps/test/features/chat/tool_registry_test.dart deleted file mode 100644 index 0d26bb8..0000000 --- a/apps/test/features/chat/tool_registry_test.dart +++ /dev/null @@ -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()), - ); - }); - - 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); - }); - }); -} diff --git a/apps/test/features/home/ui/widgets/home_chat_item_renderer_test.dart b/apps/test/features/home/ui/widgets/home_chat_item_renderer_test.dart new file mode 100644 index 0000000..df47cb1 --- /dev/null +++ b/apps/test/features/home/ui/widgets/home_chat_item_renderer_test.dart @@ -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 _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); + }); + }); +} diff --git a/apps/test/features/settings/data/models/automation_job_model_test.dart b/apps/test/features/settings/data/models/automation_job_model_test.dart new file mode 100644 index 0000000..991b941 --- /dev/null +++ b/apps/test/features/settings/data/models/automation_job_model_test.dart @@ -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 = { + '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()), + ); + }); + }); + + 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; + 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); + }); + }); +} diff --git a/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart b/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart new file mode 100644 index 0000000..2ac2377 --- /dev/null +++ b/apps/test/features/settings/presentation/cubits/automation_jobs_cubit_test.dart @@ -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( + 'loadJobs success emits loading then jobs', + build: () { + when(() => mockApi.list()).thenAnswer((_) async => [testJob]); + return cubit; + }, + act: (c) => c.loadJobs(), + expect: () => [ + isA().having( + (s) => s.isLoading, + 'isLoading', + true, + ), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.jobs, 'jobs', [testJob]), + ], + ); + + blocTest( + 'loadJobs failure emits loading then error', + build: () { + when(() => mockApi.list()).thenThrow(Exception('Network error')); + return cubit; + }, + act: (c) => c.loadJobs(), + expect: () => [ + isA().having( + (s) => s.isLoading, + 'isLoading', + true, + ), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.error, 'error', isNotNull), + ], + ); + + blocTest( + '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( + 'deleteJob failure emits error without refreshing', + build: () { + when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed')); + return cubit; + }, + act: (c) => c.deleteJob('1'), + expect: () => [ + isA().having((s) => s.error, 'error', isNotNull), + ], + verify: (_) { + verify(() => mockApi.delete('1')).called(1); + verifyNever(() => mockApi.list()); + }, + ); + + blocTest( + '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().having( + (s) => s.jobs.first.status, + 'updated status', + 'disabled', + ), + ], + verify: (_) { + verify(() => mockApi.update('1', any())).called(1); + }, + ); + + blocTest( + '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().having((s) => s.error, 'error', isNotNull), + ], + ); + }); +} diff --git a/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart b/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart new file mode 100644 index 0000000..ee569e9 --- /dev/null +++ b/apps/test/features/settings/presentation/cubits/job_detail_cubit_test.dart @@ -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( + 'loadJob success emits loading then job', + build: () { + when(() => mockApi.get(any())).thenAnswer((_) async => testJob); + return cubit; + }, + act: (c) => c.loadJob('1'), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.job, 'job', testJob), + ], + ); + + blocTest( + 'loadJob failure emits loading then error', + build: () { + when(() => mockApi.get(any())).thenThrow(Exception('Network error')); + return cubit; + }, + act: (c) => c.loadJob('1'), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.error, 'error', isNotNull), + ], + ); + + blocTest( + '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().having((s) => s.isSaving, 'isSaving', true), + isA() + .having((s) => s.isSaving, 'isSaving', false) + .having((s) => s.job, 'job', testJob), + ], + ); + + blocTest( + '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().having((s) => s.isSaving, 'isSaving', true), + isA() + .having((s) => s.isSaving, 'isSaving', false) + .having((s) => s.error, 'error', isNotNull), + ], + ); + + blocTest( + 'deleteJob success emits saving then saving false', + build: () { + when(() => mockApi.delete(any())).thenAnswer((_) async {}); + return cubit; + }, + act: (c) => c.deleteJob('1'), + expect: () => [ + isA() + .having((s) => s.isSaving, 'isSaving', true) + .having((s) => s.error, 'error', isNull), + isA().having((s) => s.isSaving, 'isSaving', false), + ], + verify: (_) { + verify(() => mockApi.delete('1')).called(1); + }, + ); + + blocTest( + 'deleteJob failure emits saving then error', + build: () { + when(() => mockApi.delete(any())).thenThrow(Exception('Delete failed')); + return cubit; + }, + act: (c) => c.deleteJob('1'), + expect: () => [ + isA() + .having((s) => s.isSaving, 'isSaving', true) + .having((s) => s.error, 'error', isNull), + isA() + .having((s) => s.isSaving, 'isSaving', false) + .having((s) => s.error, 'error', isNotNull), + ], + verify: (_) { + verify(() => mockApi.delete('1')).called(1); + }, + ); + + blocTest( + '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() + .having((s) => s.isSaving, 'isSaving', true) + .having((s) => s.error, 'error', isNull), + isA() + .having((s) => s.isSaving, 'isSaving', false) + .having((s) => s.job, 'job', testJob), + ], + verify: (_) { + verify(() => mockApi.create(any())).called(1); + }, + ); + + blocTest( + '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() + .having((s) => s.isSaving, 'isSaving', true) + .having((s) => s.error, 'error', isNull), + isA() + .having((s) => s.isSaving, 'isSaving', false) + .having((s) => s.error, 'error', isNotNull), + ], + ); + }); +} diff --git a/apps/test/shared/utils/tool_name_localizer_test.dart b/apps/test/shared/utils/tool_name_localizer_test.dart new file mode 100644 index 0000000..43dd05c --- /dev/null +++ b/apps/test/shared/utils/tool_name_localizer_test.dart @@ -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'); + }); + }); +} diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml index 4cda686..1b9b013 100644 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -13,3 +13,8 @@ context: source: latest_chat window_mode: day window_count: 2 +schedule: + type: daily + run_at: + hour: 8 + minute: 0 diff --git a/backend/src/core/config/static/route/frontend_routes.yaml b/backend/src/core/config/static/route/frontend_routes.yaml index ed8d818..658b968 100644 --- a/backend/src/core/config/static/route/frontend_routes.yaml +++ b/backend/src/core/config/static/route/frontend_routes.yaml @@ -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 diff --git a/backend/src/schemas/automation/__init__.py b/backend/src/schemas/automation/__init__.py index cb57fb9..d46cb0a 100644 --- a/backend/src/schemas/automation/__init__.py +++ b/backend/src/schemas/automation/__init__.py @@ -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 diff --git a/backend/src/v1/auth/automation_static_config.py b/backend/src/v1/auth/automation_static_config.py index 5d81cd9..4b3704a 100644 --- a/backend/src/v1/auth/automation_static_config.py +++ b/backend/src/v1/auth/automation_static_config.py @@ -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 diff --git a/backend/src/v1/auth/registration_bootstrap.py b/backend/src/v1/auth/registration_bootstrap.py index 10b9155..66df06b 100644 --- a/backend/src/v1/auth/registration_bootstrap.py +++ b/backend/src/v1/auth/registration_bootstrap.py @@ -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 diff --git a/backend/src/v1/automation_jobs/dependencies.py b/backend/src/v1/automation_jobs/dependencies.py new file mode 100644 index 0000000..38c992e --- /dev/null +++ b/backend/src/v1/automation_jobs/dependencies.py @@ -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 diff --git a/backend/src/v1/automation_jobs/repository.py b/backend/src/v1/automation_jobs/repository.py index 6721de9..0eed681 100644 --- a/backend/src/v1/automation_jobs/repository.py +++ b/backend/src/v1/automation_jobs/repository.py @@ -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) diff --git a/backend/src/v1/automation_jobs/router.py b/backend/src/v1/automation_jobs/router.py new file mode 100644 index 0000000..d386164 --- /dev/null +++ b/backend/src/v1/automation_jobs/router.py @@ -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) diff --git a/backend/src/v1/automation_jobs/schemas.py b/backend/src/v1/automation_jobs/schemas.py index 1cef382..a723f1d 100644 --- a/backend/src/v1/automation_jobs/schemas.py +++ b/backend/src/v1/automation_jobs/schemas.py @@ -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] diff --git a/backend/src/v1/automation_jobs/service.py b/backend/src/v1/automation_jobs/service.py index ddea072..9a6ec39 100644 --- a/backend/src/v1/automation_jobs/service.py +++ b/backend/src/v1/automation_jobs/service.py @@ -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", + ) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 2ec6656..12b6497 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -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) diff --git a/backend/tests/integration/v1/automation_jobs/test_router.py b/backend/tests/integration/v1/automation_jobs/test_router.py new file mode 100644 index 0000000..4a1d95f --- /dev/null +++ b/backend/tests/integration/v1/automation_jobs/test_router.py @@ -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 = {} diff --git a/backend/tests/unit/core/config/test_memory_automation_static_config.py b/backend/tests/unit/core/config/test_memory_automation_static_config.py index ead5413..9ae5d3e 100644 --- a/backend/tests/unit/core/config/test_memory_automation_static_config.py +++ b/backend/tests/unit/core/config/test_memory_automation_static_config.py @@ -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 diff --git a/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py b/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py index c8bdf41..1a90027 100644 --- a/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py +++ b/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py @@ -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) diff --git a/backend/tests/unit/v1/automation_jobs/test_repository.py b/backend/tests/unit/v1/automation_jobs/test_repository.py index 2f72b98..399744b 100644 --- a/backend/tests/unit/v1/automation_jobs/test_repository.py +++ b/backend/tests/unit/v1/automation_jobs/test_repository.py @@ -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) diff --git a/backend/tests/unit/v1/automation_jobs/test_schemas.py b/backend/tests/unit/v1/automation_jobs/test_schemas.py new file mode 100644 index 0000000..3b3725e --- /dev/null +++ b/backend/tests/unit/v1/automation_jobs/test_schemas.py @@ -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", + } + ) diff --git a/backend/tests/unit/v1/automation_jobs/test_service.py b/backend/tests/unit/v1/automation_jobs/test_service.py new file mode 100644 index 0000000..941b5f4 --- /dev/null +++ b/backend/tests/unit/v1/automation_jobs/test_service.py @@ -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() diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index f868ad8..1a0db9b 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -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 diff --git a/docs/bugs/2026-03-24-events-not-rendering.md b/docs/bugs/2026-03-24-events-not-rendering.md new file mode 100644 index 0000000..63d2cad --- /dev/null +++ b/docs/bugs/2026-03-24-events-not-rendering.md @@ -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` 完整链路 + +## 当前状态 + +- 状态:待定位 +- 优先级:高 diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index 3edc59d..ab4a601 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -171,6 +171,35 @@ data: - `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`