From 6be616f108b3a642d5703a0f9b0784e4ae3254ce Mon Sep 17 00:00:00 2001 From: qzl Date: Mon, 23 Mar 2026 14:25:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20memory=20=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=EF=BC=8C=E6=94=AF=E6=8C=81=20user=20memory=20?= =?UTF-8?q?=E5=92=8C=20work=20memory=20=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/lib/core/api/api_client.dart | 13 + apps/lib/core/api/i_api_client.dart | 1 + apps/lib/core/di/injection.dart | 4 + apps/lib/core/router/app_router.dart | 12 + apps/lib/core/router/app_routes.dart | 2 + .../chat/data/models/ag_ui_event.dart | 3 - .../chat/data/services/ag_ui_service.dart | 2 +- .../settings/data/models/memory_models.dart | 951 ++++++++++++++++++ .../data/services/memory_service.dart | 92 +- .../settings/ui/screens/memory_screen.dart | 600 ++++++++--- .../ui/screens/user_memory_detail_screen.dart | 942 +++++++++++++++++ .../ui/screens/work_memory_detail_screen.dart | 889 ++++++++++++++++ apps/test/features/chat/ag_ui_event_test.dart | 2 +- .../data/services/ag_ui_service_test.dart | 5 + .../chat_bloc_attachment_sync_test.dart | 6 +- .../ui/widgets/home_screen_layout_test.dart | 5 + .../ui/screens/settings_screen_test.dart | 5 + ...260323_0001_drop_memories_source_column.py | 38 + ...0260323_0002_drop_memories_title_column.py | 34 + ...03_bootstrap_job_key_and_unique_indexes.py | 355 +++++++ .../src/core/agentscope/prompts/__init__.py | 8 +- .../core/agentscope/prompts/memory_prompt.py | 59 +- .../core/agentscope/prompts/system_prompt.py | 18 +- .../core/agentscope/runtime/orchestrator.py | 11 +- backend/src/core/agentscope/runtime/runner.py | 25 +- backend/src/core/agentscope/runtime/tasks.py | 22 +- .../agentscope/services/context_service.py | 4 +- .../core/agentscope/tools/custom/__init__.py | 6 + .../core/agentscope/tools/custom/memory.py | 330 ++++++ .../src/core/agentscope/tools/tool_config.py | 27 +- backend/src/core/agentscope/tools/toolkit.py | 6 + .../agentscope/tools/utils/memory_domain.py | 32 + backend/src/core/automation/__init__.py | 12 +- .../static/automation/memory_extraction.yaml | 8 + backend/src/models/automation_jobs.py | 4 + backend/src/models/memories.py | 11 - backend/src/schemas/__init__.py | 2 - backend/src/schemas/automation/__init__.py | 6 +- backend/src/schemas/memories/__init__.py | 51 +- .../src/schemas/memories/memory_content.py | 192 ++++ backend/src/v1/agent/system_agents_config.py | 13 +- .../src/v1/auth/automation_static_config.py | 46 + backend/src/v1/auth/dependencies.py | 23 +- backend/src/v1/auth/registration_bootstrap.py | 228 +++++ backend/src/v1/auth/schemas.py | 8 + backend/src/v1/auth/service.py | 20 +- backend/src/v1/memories/dependencies.py | 30 + backend/src/v1/memories/repository.py | 155 ++- backend/src/v1/memories/router.py | 83 ++ backend/src/v1/memories/schemas.py | 38 + backend/src/v1/memories/service.py | 305 +++++- backend/src/v1/router.py | 3 +- .../agentscope/runtime/test_orchestrator.py | 4 +- .../core/agentscope/runtime/test_runner.py | 4 +- .../core/agentscope/runtime/test_tasks.py | 18 +- .../unit/core/agentscope/test_memory_tools.py | 113 +++ .../core/agentscope/test_system_prompt.py | 50 +- .../unit/core/agentscope/test_toolkit.py | 22 - .../core/agentscope/test_toolkit_registry.py | 3 +- .../test_memory_automation_static_config.py | 17 + .../test_automation_job_migration_contract.py | 15 + .../tests/unit/v1/auth/test_auth_service.py | 29 + .../test_registration_bootstrap_service.py | 112 +++ .../2026-03-23-memories-ui-implementation.md | 231 +++++ docs/plans/2026-03-23-memory-system-design.md | 78 ++ docs/protocols/calendar/schedule-items.md | 262 +++++ docs/protocols/models/friendships.md | 188 ++++ docs/protocols/models/inbox-messages.md | 128 +++ docs/protocols/models/memory.md | 322 ++++++ docs/protocols/models/users.md | 119 +++ 70 files changed, 7031 insertions(+), 431 deletions(-) create mode 100644 apps/lib/features/settings/data/models/memory_models.dart create mode 100644 apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart create mode 100644 apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart create mode 100644 backend/alembic/versions/20260323_0001_drop_memories_source_column.py create mode 100644 backend/alembic/versions/20260323_0002_drop_memories_title_column.py create mode 100644 backend/alembic/versions/20260323_0003_bootstrap_job_key_and_unique_indexes.py create mode 100644 backend/src/core/agentscope/tools/custom/memory.py create mode 100644 backend/src/core/agentscope/tools/utils/memory_domain.py create mode 100644 backend/src/core/config/static/automation/memory_extraction.yaml create mode 100644 backend/src/schemas/memories/memory_content.py create mode 100644 backend/src/v1/auth/automation_static_config.py create mode 100644 backend/src/v1/auth/registration_bootstrap.py create mode 100644 backend/src/v1/memories/dependencies.py create mode 100644 backend/src/v1/memories/router.py create mode 100644 backend/src/v1/memories/schemas.py create mode 100644 backend/tests/unit/core/agentscope/test_memory_tools.py create mode 100644 backend/tests/unit/core/config/test_memory_automation_static_config.py create mode 100644 backend/tests/unit/v1/auth/test_registration_bootstrap_service.py create mode 100644 docs/plans/2026-03-23-memories-ui-implementation.md create mode 100644 docs/plans/2026-03-23-memory-system-design.md create mode 100644 docs/protocols/calendar/schedule-items.md create mode 100644 docs/protocols/models/friendships.md create mode 100644 docs/protocols/models/inbox-messages.md create mode 100644 docs/protocols/models/memory.md create mode 100644 docs/protocols/models/users.md diff --git a/apps/lib/core/api/api_client.dart b/apps/lib/core/api/api_client.dart index 29ddca4..01fc04b 100644 --- a/apps/lib/core/api/api_client.dart +++ b/apps/lib/core/api/api_client.dart @@ -98,6 +98,19 @@ class ApiClient implements IApiClient { } } + @override + Future> put( + String path, { + dynamic data, + Options? options, + }) async { + try { + return await _dio.put(path, data: data, options: options); + } on DioException catch (e) { + throw ApiException.fromDioError(e); + } + } + @override Future> delete( String path, { diff --git a/apps/lib/core/api/i_api_client.dart b/apps/lib/core/api/i_api_client.dart index cc6defd..2488a2b 100644 --- a/apps/lib/core/api/i_api_client.dart +++ b/apps/lib/core/api/i_api_client.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; abstract class IApiClient { Future> get(String path, {Options? options}); Future> post(String path, {dynamic data, Options? options}); + Future> put(String path, {dynamic data, Options? options}); Future> patch(String path, {dynamic data, Options? options}); Future> delete(String path, {dynamic data, Options? options}); Future> getSseLines( diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index b9ad3fa..6d9c852 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -26,6 +26,7 @@ import '../../features/messages/data/inbox_api.dart'; import '../../features/settings/data/settings_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'; import '../../features/users/data/users_api.dart'; import '../../features/todo/data/todo_api.dart'; import '../../features/todo/data/todo_repository.dart'; @@ -111,6 +112,9 @@ Future configureDependencies() async { final settingsApi = SettingsApi(apiClient); sl.registerSingleton(settingsApi); + final memoryService = MemoryService(apiClient); + sl.registerSingleton(memoryService); + sl.registerSingleton( SettingsUserCache(userProfileCacheRepository), ); diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 1aada62..f25f02c 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -24,6 +24,8 @@ 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/memory_screen.dart'; +import '../../features/settings/ui/screens/user_memory_detail_screen.dart'; +import '../../features/settings/ui/screens/work_memory_detail_screen.dart'; import '../../features/settings/ui/screens/edit_profile_screen.dart'; final _homeSecondLevelRoutes = [ @@ -41,6 +43,8 @@ final _protectedRoutes = [ '/calendar/events', AppRoutes.settingsFeatures, AppRoutes.settingsMemory, + AppRoutes.settingsMemoryUser, + AppRoutes.settingsMemoryWork, AppRoutes.settingsEditProfile, AppRoutes.messageInviteList, ]; @@ -176,6 +180,14 @@ GoRouter createAppRouter(AuthBloc authBloc) { path: AppRoutes.settingsMemory, builder: (context, state) => const MemoryScreen(), ), + GoRoute( + path: AppRoutes.settingsMemoryUser, + builder: (context, state) => const UserMemoryDetailScreen(), + ), + GoRoute( + path: AppRoutes.settingsMemoryWork, + builder: (context, state) => const WorkMemoryDetailScreen(), + ), GoRoute( path: AppRoutes.settingsEditProfile, builder: (context, state) => const EditProfileScreen(), diff --git a/apps/lib/core/router/app_routes.dart b/apps/lib/core/router/app_routes.dart index 5f5b733..cf9f7b2 100644 --- a/apps/lib/core/router/app_routes.dart +++ b/apps/lib/core/router/app_routes.dart @@ -30,5 +30,7 @@ class AppRoutes { static const settingsMain = '/settings'; static const settingsFeatures = '/settings/features'; static const settingsMemory = '/settings/memory'; + static const settingsMemoryUser = '/settings/memory/user'; + static const settingsMemoryWork = '/settings/memory/work'; static const settingsEditProfile = '/edit-profile'; } diff --git a/apps/lib/features/chat/data/models/ag_ui_event.dart b/apps/lib/features/chat/data/models/ag_ui_event.dart index 5863558..318f020 100644 --- a/apps/lib/features/chat/data/models/ag_ui_event.dart +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -226,7 +226,6 @@ class ToolCallResultEvent extends AgUiEvent { required this.toolName, required this.resultSummary, required this.status, - required this.uiSchema, }) : super(type: AgUiEventType.toolCallResult); final String messageId; @@ -234,7 +233,6 @@ class ToolCallResultEvent extends AgUiEvent { final String toolName; final String resultSummary; final String status; - final Map? uiSchema; factory ToolCallResultEvent.fromJson(Map json) => ToolCallResultEvent( @@ -243,7 +241,6 @@ class ToolCallResultEvent extends AgUiEvent { toolName: _asString(json['tool_name']), resultSummary: _asString(json['result']), status: _asString(json['status'], fallback: 'success'), - uiSchema: _asMap(json['ui_schema']), ); } diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index 397c7dc..aaa230d 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -363,7 +363,7 @@ class AgUiService { ], 'tools': >[], 'context': >[], - 'forwardedProps': {}, + 'forwardedProps': {'runtime_mode': 'chat'}, }, uploadedAttachments: uploadedAttachments, ); diff --git a/apps/lib/features/settings/data/models/memory_models.dart b/apps/lib/features/settings/data/models/memory_models.dart new file mode 100644 index 0000000..38a6ca1 --- /dev/null +++ b/apps/lib/features/settings/data/models/memory_models.dart @@ -0,0 +1,951 @@ +class PersonMeta { + final String? source; + final double? confidence; + final DateTime? lastUpdatedAt; + + PersonMeta({this.source, this.confidence, this.lastUpdatedAt}); + + factory PersonMeta.fromJson(Map? json) { + if (json == null) return PersonMeta(); + return PersonMeta( + source: json['source'] as String?, + confidence: (json['confidence'] as num?)?.toDouble(), + lastUpdatedAt: json['last_updated_at'] != null + ? DateTime.parse(json['last_updated_at'] as String) + : null, + ); + } + + Map toJson() => { + 'source': source, + 'confidence': confidence, + 'last_updated_at': lastUpdatedAt?.toIso8601String(), + }; +} + +class Person { + final String name; + final String? relationship; + final String? role; + final String? preferredContactChannel; + final String? notes; + final PersonMeta? meta; + + Person({ + required this.name, + this.relationship, + this.role, + this.preferredContactChannel, + this.notes, + this.meta, + }); + + factory Person.fromJson(Map json) { + return Person( + name: json['name'] as String? ?? '', + relationship: json['relationship'] as String?, + role: json['role'] as String?, + preferredContactChannel: json['preferred_contact_channel'] as String?, + notes: json['notes'] as String?, + meta: json['meta'] != null + ? PersonMeta.fromJson(json['meta'] as Map?) + : null, + ); + } + + Map toJson() => { + 'name': name, + 'relationship': relationship, + 'role': role, + 'preferred_contact_channel': preferredContactChannel, + 'notes': notes, + 'meta': meta?.toJson(), + }; + + Person copyWith({ + String? name, + String? relationship, + String? role, + String? preferredContactChannel, + String? notes, + PersonMeta? meta, + }) { + return Person( + name: name ?? this.name, + relationship: relationship ?? this.relationship, + role: role ?? this.role, + preferredContactChannel: + preferredContactChannel ?? this.preferredContactChannel, + notes: notes ?? this.notes, + meta: meta ?? this.meta, + ); + } +} + +class PlaceMeta { + final String? source; + final double? confidence; + final DateTime? lastUpdatedAt; + + PlaceMeta({this.source, this.confidence, this.lastUpdatedAt}); + + factory PlaceMeta.fromJson(Map? json) { + if (json == null) return PlaceMeta(); + return PlaceMeta( + source: json['source'] as String?, + confidence: (json['confidence'] as num?)?.toDouble(), + lastUpdatedAt: json['last_updated_at'] != null + ? DateTime.parse(json['last_updated_at'] as String) + : null, + ); + } + + Map toJson() => { + 'source': source, + 'confidence': confidence, + 'last_updated_at': lastUpdatedAt?.toIso8601String(), + }; +} + +class Place { + final String name; + final String? category; + final String? address; + final String? timezone; + final int? commuteMinutes; + final String? preference; + final String? notes; + final PlaceMeta? meta; + + Place({ + required this.name, + this.category, + this.address, + this.timezone, + this.commuteMinutes, + this.preference, + this.notes, + this.meta, + }); + + factory Place.fromJson(Map json) { + return Place( + name: json['name'] as String? ?? '', + category: json['category'] as String?, + address: json['address'] as String?, + timezone: json['timezone'] as String?, + commuteMinutes: json['commute_minutes'] as int?, + preference: json['preference'] as String?, + notes: json['notes'] as String?, + meta: json['meta'] != null + ? PlaceMeta.fromJson(json['meta'] as Map?) + : null, + ); + } + + Map toJson() => { + 'name': name, + 'category': category, + 'address': address, + 'timezone': timezone, + 'commute_minutes': commuteMinutes, + 'preference': preference, + 'notes': notes, + 'meta': meta?.toJson(), + }; + + Place copyWith({ + String? name, + String? category, + String? address, + String? timezone, + int? commuteMinutes, + String? preference, + String? notes, + PlaceMeta? meta, + }) { + return Place( + name: name ?? this.name, + category: category ?? this.category, + address: address ?? this.address, + timezone: timezone ?? this.timezone, + commuteMinutes: commuteMinutes ?? this.commuteMinutes, + preference: preference ?? this.preference, + notes: notes ?? this.notes, + meta: meta ?? this.meta, + ); + } +} + +class UserPreferences { + final String? communicationStyle; + final List languagePreference; + final String? locationPreference; + final String? workLifestyle; + final List notificationPreference; + + UserPreferences({ + this.communicationStyle, + this.languagePreference = const [], + this.locationPreference, + this.workLifestyle, + this.notificationPreference = const [], + }); + + factory UserPreferences.fromJson(Map? json) { + if (json == null) return UserPreferences(); + return UserPreferences( + communicationStyle: json['communication_style'] as String?, + languagePreference: + (json['language_preference'] as List?)?.cast() ?? [], + locationPreference: json['location_preference'] as String?, + workLifestyle: json['work_lifestyle'] as String?, + notificationPreference: + (json['notification_preference'] as List?)?.cast() ?? + [], + ); + } + + Map toJson() => { + 'communication_style': communicationStyle, + 'language_preference': languagePreference, + 'location_preference': locationPreference, + 'work_lifestyle': workLifestyle, + 'notification_preference': notificationPreference, + }; + + UserPreferences copyWith({ + String? communicationStyle, + List? languagePreference, + String? locationPreference, + String? workLifestyle, + List? notificationPreference, + }) { + return UserPreferences( + communicationStyle: communicationStyle ?? this.communicationStyle, + languagePreference: languagePreference ?? this.languagePreference, + locationPreference: locationPreference ?? this.locationPreference, + workLifestyle: workLifestyle ?? this.workLifestyle, + notificationPreference: + notificationPreference ?? this.notificationPreference, + ); + } +} + +class TimeWindow { + final List weekdays; + final String? start; + final String? end; + + TimeWindow({this.weekdays = const [], this.start, this.end}); + + factory TimeWindow.fromJson(Map json) { + return TimeWindow( + weekdays: (json['weekdays'] as List?)?.cast() ?? [], + start: json['start'] as String?, + end: json['end'] as String?, + ); + } + + Map toJson() => { + 'weekdays': weekdays, + 'start': start, + 'end': end, + }; +} + +class SchedulingPreferences { + final List productiveWindows; + final List preferredMeetingWindows; + final List noMeetingWindows; + final List deepWorkWindows; + final List preferredMeetingDurationMinutes; + final int? meetingBufferMinutes; + final int? maxMeetingsPerDay; + final String? notes; + + SchedulingPreferences({ + this.productiveWindows = const [], + this.preferredMeetingWindows = const [], + this.noMeetingWindows = const [], + this.deepWorkWindows = const [], + this.preferredMeetingDurationMinutes = const [30, 60], + this.meetingBufferMinutes, + this.maxMeetingsPerDay, + this.notes, + }); + + factory SchedulingPreferences.fromJson(Map? json) { + if (json == null) return SchedulingPreferences(); + return SchedulingPreferences( + productiveWindows: + (json['productive_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + preferredMeetingWindows: + (json['preferred_meeting_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + noMeetingWindows: + (json['no_meeting_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + deepWorkWindows: + (json['deep_work_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + preferredMeetingDurationMinutes: + (json['preferred_meeting_duration_minutes'] as List?) + ?.cast() ?? + [30, 60], + meetingBufferMinutes: json['meeting_buffer_minutes'] as int?, + maxMeetingsPerDay: json['max_meetings_per_day'] as int?, + notes: json['notes'] as String?, + ); + } + + Map toJson() => { + 'productive_windows': productiveWindows.map((e) => e.toJson()).toList(), + 'preferred_meeting_windows': preferredMeetingWindows + .map((e) => e.toJson()) + .toList(), + 'no_meeting_windows': noMeetingWindows.map((e) => e.toJson()).toList(), + 'deep_work_windows': deepWorkWindows.map((e) => e.toJson()).toList(), + 'preferred_meeting_duration_minutes': preferredMeetingDurationMinutes, + 'meeting_buffer_minutes': meetingBufferMinutes, + 'max_meetings_per_day': maxMeetingsPerDay, + 'notes': notes, + }; + + SchedulingPreferences copyWith({ + List? productiveWindows, + List? preferredMeetingWindows, + List? noMeetingWindows, + List? deepWorkWindows, + List? preferredMeetingDurationMinutes, + int? meetingBufferMinutes, + int? maxMeetingsPerDay, + String? notes, + }) { + return SchedulingPreferences( + productiveWindows: productiveWindows ?? this.productiveWindows, + preferredMeetingWindows: + preferredMeetingWindows ?? this.preferredMeetingWindows, + noMeetingWindows: noMeetingWindows ?? this.noMeetingWindows, + deepWorkWindows: deepWorkWindows ?? this.deepWorkWindows, + preferredMeetingDurationMinutes: + preferredMeetingDurationMinutes ?? + this.preferredMeetingDurationMinutes, + meetingBufferMinutes: meetingBufferMinutes ?? this.meetingBufferMinutes, + maxMeetingsPerDay: maxMeetingsPerDay ?? this.maxMeetingsPerDay, + notes: notes ?? this.notes, + ); + } +} + +class RecurringRoutineMeta { + final String? source; + final double? confidence; + final DateTime? lastUpdatedAt; + + RecurringRoutineMeta({this.source, this.confidence, this.lastUpdatedAt}); + + factory RecurringRoutineMeta.fromJson(Map? json) { + if (json == null) return RecurringRoutineMeta(); + return RecurringRoutineMeta( + source: json['source'] as String?, + confidence: (json['confidence'] as num?)?.toDouble(), + lastUpdatedAt: json['last_updated_at'] != null + ? DateTime.parse(json['last_updated_at'] as String) + : null, + ); + } + + Map toJson() => { + 'source': source, + 'confidence': confidence, + 'last_updated_at': lastUpdatedAt?.toIso8601String(), + }; +} + +class RecurringRoutine { + final String name; + final String? description; + final String? cadence; + final List timeWindows; + final String? importance; + final RecurringRoutineMeta? meta; + + RecurringRoutine({ + required this.name, + this.description, + this.cadence, + this.timeWindows = const [], + this.importance, + this.meta, + }); + + factory RecurringRoutine.fromJson(Map json) { + return RecurringRoutine( + name: json['name'] as String? ?? '', + description: json['description'] as String?, + cadence: json['cadence'] as String?, + timeWindows: + (json['time_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + importance: json['importance'] as String?, + meta: json['meta'] != null + ? RecurringRoutineMeta.fromJson(json['meta'] as Map?) + : null, + ); + } + + Map toJson() => { + 'name': name, + 'description': description, + 'cadence': cadence, + 'time_windows': timeWindows.map((e) => e.toJson()).toList(), + 'importance': importance, + 'meta': meta?.toJson(), + }; + + RecurringRoutine copyWith({ + String? name, + String? description, + String? cadence, + List? timeWindows, + String? importance, + RecurringRoutineMeta? meta, + }) { + return RecurringRoutine( + name: name ?? this.name, + description: description ?? this.description, + cadence: cadence ?? this.cadence, + timeWindows: timeWindows ?? this.timeWindows, + importance: importance ?? this.importance, + meta: meta ?? this.meta, + ); + } +} + +class UserMemoryContent { + final String? occupation; + final String? timezone; + final String? primaryLanguage; + final List people; + final List places; + final UserPreferences preferences; + final SchedulingPreferences schedulingPreferences; + final List interests; + final List avoidTopics; + final List customRules; + final List recurringRoutines; + + UserMemoryContent({ + this.occupation, + this.timezone, + this.primaryLanguage, + this.people = const [], + this.places = const [], + UserPreferences? preferences, + SchedulingPreferences? schedulingPreferences, + this.interests = const [], + this.avoidTopics = const [], + this.customRules = const [], + this.recurringRoutines = const [], + }) : preferences = preferences ?? UserPreferences(), + schedulingPreferences = schedulingPreferences ?? SchedulingPreferences(); + + factory UserMemoryContent.fromJson(Map? json) { + if (json == null) return UserMemoryContent(); + return UserMemoryContent( + occupation: json['occupation'] as String?, + timezone: json['timezone'] as String?, + primaryLanguage: json['primary_language'] as String?, + people: + (json['people'] as List?) + ?.map((e) => Person.fromJson(e as Map)) + .toList() ?? + [], + places: + (json['places'] as List?) + ?.map((e) => Place.fromJson(e as Map)) + .toList() ?? + [], + preferences: UserPreferences.fromJson( + json['preferences'] as Map?, + ), + schedulingPreferences: SchedulingPreferences.fromJson( + json['scheduling_preferences'] as Map?, + ), + interests: (json['interests'] as List?)?.cast() ?? [], + avoidTopics: + (json['avoid_topics'] as List?)?.cast() ?? [], + customRules: + (json['custom_rules'] as List?)?.cast() ?? [], + recurringRoutines: + (json['recurring_routines'] as List?) + ?.map((e) => RecurringRoutine.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + Map toJson() => { + 'occupation': occupation, + 'timezone': timezone, + 'primary_language': primaryLanguage, + 'people': people.map((e) => e.toJson()).toList(), + 'places': places.map((e) => e.toJson()).toList(), + 'preferences': preferences.toJson(), + 'scheduling_preferences': schedulingPreferences.toJson(), + 'interests': interests, + 'avoid_topics': avoidTopics, + 'custom_rules': customRules, + 'recurring_routines': recurringRoutines.map((e) => e.toJson()).toList(), + }; + + UserMemoryContent copyWith({ + String? occupation, + String? timezone, + String? primaryLanguage, + List? people, + List? places, + UserPreferences? preferences, + SchedulingPreferences? schedulingPreferences, + List? interests, + List? avoidTopics, + List? customRules, + List? recurringRoutines, + }) { + return UserMemoryContent( + occupation: occupation ?? this.occupation, + timezone: timezone ?? this.timezone, + primaryLanguage: primaryLanguage ?? this.primaryLanguage, + people: people ?? this.people, + places: places ?? this.places, + preferences: preferences ?? this.preferences, + schedulingPreferences: + schedulingPreferences ?? this.schedulingPreferences, + interests: interests ?? this.interests, + avoidTopics: avoidTopics ?? this.avoidTopics, + customRules: customRules ?? this.customRules, + recurringRoutines: recurringRoutines ?? this.recurringRoutines, + ); + } + + String get summary { + final parts = []; + if (occupation != null && occupation!.isNotEmpty) { + parts.add(occupation!); + } + if (people.isNotEmpty) { + parts.add('${people.length} 位联系人'); + } + if (places.isNotEmpty) { + parts.add('${places.length} 个地点'); + } + if (interests.isNotEmpty) { + parts.add('${interests.length} 个兴趣'); + } + return parts.isEmpty ? '暂无信息' : parts.join(' · '); + } +} + +class KeyMilestone { + final String name; + final DateTime? dueDate; + final String? status; + final String? notes; + + KeyMilestone({required this.name, this.dueDate, this.status, this.notes}); + + factory KeyMilestone.fromJson(Map json) { + return KeyMilestone( + name: json['name'] as String? ?? '', + dueDate: json['due_date'] != null + ? DateTime.parse(json['due_date'] as String) + : null, + status: json['status'] as String?, + notes: json['notes'] as String?, + ); + } + + Map toJson() => { + 'name': name, + 'due_date': dueDate?.toIso8601String().split('T').first, + 'status': status, + 'notes': notes, + }; +} + +class CurrentProject { + final String name; + final String? description; + final String? status; + final String? priority; + final DateTime? deadline; + final List collaborators; + final List keyMilestones; + final String? notes; + + CurrentProject({ + required this.name, + this.description, + this.status, + this.priority, + this.deadline, + this.collaborators = const [], + this.keyMilestones = const [], + this.notes, + }); + + factory CurrentProject.fromJson(Map json) { + return CurrentProject( + name: json['name'] as String? ?? '', + description: json['description'] as String?, + status: json['status'] as String?, + priority: json['priority'] as String?, + deadline: json['deadline'] != null + ? DateTime.parse(json['deadline'] as String) + : null, + collaborators: + (json['collaborators'] as List?)?.cast() ?? [], + keyMilestones: + (json['key_milestones'] as List?) + ?.map((e) => KeyMilestone.fromJson(e as Map)) + .toList() ?? + [], + notes: json['notes'] as String?, + ); + } + + Map toJson() => { + 'name': name, + 'description': description, + 'status': status, + 'priority': priority, + 'deadline': deadline?.toIso8601String().split('T').first, + 'collaborators': collaborators, + 'key_milestones': keyMilestones.map((e) => e.toJson()).toList(), + 'notes': notes, + }; + + CurrentProject copyWith({ + String? name, + String? description, + String? status, + String? priority, + DateTime? deadline, + List? collaborators, + List? keyMilestones, + String? notes, + }) { + return CurrentProject( + name: name ?? this.name, + description: description ?? this.description, + status: status ?? this.status, + priority: priority ?? this.priority, + deadline: deadline ?? this.deadline, + collaborators: collaborators ?? this.collaborators, + keyMilestones: keyMilestones ?? this.keyMilestones, + notes: notes ?? this.notes, + ); + } +} + +class WorkHabits { + final List availableHours; + final List deepWorkBlocks; + final List preferredMeetingWindows; + final List noMeetingWindows; + final List preferredMeetingDurationMinutes; + final String? notificationChannel; + final String? notes; + + WorkHabits({ + this.availableHours = const [], + this.deepWorkBlocks = const [], + this.preferredMeetingWindows = const [], + this.noMeetingWindows = const [], + this.preferredMeetingDurationMinutes = const [30, 60], + this.notificationChannel, + this.notes, + }); + + factory WorkHabits.fromJson(Map? json) { + if (json == null) return WorkHabits(); + return WorkHabits( + availableHours: + (json['available_hours'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + deepWorkBlocks: + (json['deep_work_blocks'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + preferredMeetingWindows: + (json['preferred_meeting_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + noMeetingWindows: + (json['no_meeting_windows'] as List?) + ?.map((e) => TimeWindow.fromJson(e as Map)) + .toList() ?? + [], + preferredMeetingDurationMinutes: + (json['preferred_meeting_duration_minutes'] as List?) + ?.cast() ?? + [30, 60], + notificationChannel: json['notification_channel'] as String?, + notes: json['notes'] as String?, + ); + } + + Map toJson() => { + 'available_hours': availableHours.map((e) => e.toJson()).toList(), + 'deep_work_blocks': deepWorkBlocks.map((e) => e.toJson()).toList(), + 'preferred_meeting_windows': preferredMeetingWindows + .map((e) => e.toJson()) + .toList(), + 'no_meeting_windows': noMeetingWindows.map((e) => e.toJson()).toList(), + 'preferred_meeting_duration_minutes': preferredMeetingDurationMinutes, + 'notification_channel': notificationChannel, + 'notes': notes, + }; + + WorkHabits copyWith({ + List? availableHours, + List? deepWorkBlocks, + List? preferredMeetingWindows, + List? noMeetingWindows, + List? preferredMeetingDurationMinutes, + String? notificationChannel, + String? notes, + }) { + return WorkHabits( + availableHours: availableHours ?? this.availableHours, + deepWorkBlocks: deepWorkBlocks ?? this.deepWorkBlocks, + preferredMeetingWindows: + preferredMeetingWindows ?? this.preferredMeetingWindows, + noMeetingWindows: noMeetingWindows ?? this.noMeetingWindows, + preferredMeetingDurationMinutes: + preferredMeetingDurationMinutes ?? + this.preferredMeetingDurationMinutes, + notificationChannel: notificationChannel ?? this.notificationChannel, + notes: notes ?? this.notes, + ); + } +} + +class TeamMemberMeta { + final String? source; + final double? confidence; + final DateTime? lastUpdatedAt; + + TeamMemberMeta({this.source, this.confidence, this.lastUpdatedAt}); + + factory TeamMemberMeta.fromJson(Map? json) { + if (json == null) return TeamMemberMeta(); + return TeamMemberMeta( + source: json['source'] as String?, + confidence: (json['confidence'] as num?)?.toDouble(), + lastUpdatedAt: json['last_updated_at'] != null + ? DateTime.parse(json['last_updated_at'] as String) + : null, + ); + } + + Map toJson() => { + 'source': source, + 'confidence': confidence, + 'last_updated_at': lastUpdatedAt?.toIso8601String(), + }; +} + +class TeamMember { + final String name; + final String? role; + final String? relationship; + final String? preferredContactChannel; + final String? notes; + final TeamMemberMeta? meta; + + TeamMember({ + required this.name, + this.role, + this.relationship, + this.preferredContactChannel, + this.notes, + this.meta, + }); + + factory TeamMember.fromJson(Map json) { + return TeamMember( + name: json['name'] as String? ?? '', + role: json['role'] as String?, + relationship: json['relationship'] as String?, + preferredContactChannel: json['preferred_contact_channel'] as String?, + notes: json['notes'] as String?, + meta: json['meta'] != null + ? TeamMemberMeta.fromJson(json['meta'] as Map?) + : null, + ); + } + + Map toJson() => { + 'name': name, + 'role': role, + 'relationship': relationship, + 'preferred_contact_channel': preferredContactChannel, + 'notes': notes, + 'meta': meta?.toJson(), + }; + + TeamMember copyWith({ + String? name, + String? role, + String? relationship, + String? preferredContactChannel, + String? notes, + TeamMemberMeta? meta, + }) { + return TeamMember( + name: name ?? this.name, + role: role ?? this.role, + relationship: relationship ?? this.relationship, + preferredContactChannel: + preferredContactChannel ?? this.preferredContactChannel, + notes: notes ?? this.notes, + meta: meta ?? this.meta, + ); + } +} + +class WorkProfileContent { + final String? occupation; + final List expertise; + final List preferredTools; + final List currentProjects; + final WorkHabits workHabits; + final List teamMembers; + final String? teamContext; + final List workRules; + + WorkProfileContent({ + this.occupation, + this.expertise = const [], + this.preferredTools = const [], + this.currentProjects = const [], + WorkHabits? workHabits, + this.teamMembers = const [], + this.teamContext, + this.workRules = const [], + }) : workHabits = workHabits ?? WorkHabits(); + + factory WorkProfileContent.fromJson(Map? json) { + if (json == null) return WorkProfileContent(); + return WorkProfileContent( + occupation: json['occupation'] as String?, + expertise: (json['expertise'] as List?)?.cast() ?? [], + preferredTools: + (json['preferred_tools'] as List?)?.cast() ?? [], + currentProjects: + (json['current_projects'] as List?) + ?.map((e) => CurrentProject.fromJson(e as Map)) + .toList() ?? + [], + workHabits: WorkHabits.fromJson( + json['work_habits'] as Map?, + ), + teamMembers: + (json['team_members'] as List?) + ?.map((e) => TeamMember.fromJson(e as Map)) + .toList() ?? + [], + teamContext: json['team_context'] as String?, + workRules: (json['work_rules'] as List?)?.cast() ?? [], + ); + } + + Map toJson() => { + 'occupation': occupation, + 'expertise': expertise, + 'preferred_tools': preferredTools, + 'current_projects': currentProjects.map((e) => e.toJson()).toList(), + 'work_habits': workHabits.toJson(), + 'team_members': teamMembers.map((e) => e.toJson()).toList(), + 'team_context': teamContext, + 'work_rules': workRules, + }; + + WorkProfileContent copyWith({ + String? occupation, + List? expertise, + List? preferredTools, + List? currentProjects, + WorkHabits? workHabits, + List? teamMembers, + String? teamContext, + List? workRules, + }) { + return WorkProfileContent( + occupation: occupation ?? this.occupation, + expertise: expertise ?? this.expertise, + preferredTools: preferredTools ?? this.preferredTools, + currentProjects: currentProjects ?? this.currentProjects, + workHabits: workHabits ?? this.workHabits, + teamMembers: teamMembers ?? this.teamMembers, + teamContext: teamContext ?? this.teamContext, + workRules: workRules ?? this.workRules, + ); + } + + String get summary { + final parts = []; + if (occupation != null && occupation!.isNotEmpty) { + parts.add(occupation!); + } + if (expertise.isNotEmpty) { + parts.add('${expertise.length} 项专长'); + } + if (currentProjects.isNotEmpty) { + parts.add('${currentProjects.length} 个项目'); + } + if (teamMembers.isNotEmpty) { + parts.add('${teamMembers.length} 位团队成员'); + } + return parts.isEmpty ? '暂无信息' : parts.join(' · '); + } +} + +class MemoryListResponse { + final UserMemoryContent? userMemory; + final WorkProfileContent? workMemory; + + MemoryListResponse({this.userMemory, this.workMemory}); + + factory MemoryListResponse.fromJson(Map json) { + return MemoryListResponse( + userMemory: json['user_memory'] != null + ? UserMemoryContent.fromJson( + json['user_memory'] as Map?, + ) + : null, + workMemory: json['work_memory'] != null + ? WorkProfileContent.fromJson( + json['work_memory'] as Map?, + ) + : null, + ); + } +} diff --git a/apps/lib/features/settings/data/services/memory_service.dart b/apps/lib/features/settings/data/services/memory_service.dart index c36b75f..534c3c2 100644 --- a/apps/lib/features/settings/data/services/memory_service.dart +++ b/apps/lib/features/settings/data/services/memory_service.dart @@ -1,38 +1,64 @@ -import 'package:flutter/material.dart'; - -class MemoryItemModel { - final String id; - final IconData icon; - final String title; - final String subtitle; - - MemoryItemModel({ - required this.id, - required this.icon, - required this.title, - required this.subtitle, - }); -} - -class MockMemoryService { - static final MockMemoryService _instance = MockMemoryService._internal(); - factory MockMemoryService() => _instance; - - final List _items = []; - - MockMemoryService._internal(); - - List get items => List.unmodifiable(_items); - - List fetchMemoryItems() { - return items; - } -} +import 'package:social_app/core/api/i_api_client.dart'; +import '../models/memory_models.dart'; class MemoryService { - final MockMemoryService _mock = MockMemoryService(); + final IApiClient _client; + static const _prefix = '/api/v1/memories'; - List getMemoryItems() { - return _mock.fetchMemoryItems(); + MemoryService(this._client); + + Future getAllMemories() async { + final response = await _client.get>(_prefix); + return MemoryListResponse.fromJson(response.data!); + } + + Future getUserMemory() async { + final response = await _client.get>('$_prefix/user'); + if (response.data == null) return null; + return UserMemoryContent.fromJson(response.data); + } + + Future getWorkMemory() async { + final response = await _client.get>('$_prefix/work'); + if (response.data == null) return null; + return WorkProfileContent.fromJson(response.data); + } + + Future updateUserMemory(UserMemoryContent content) async { + final response = await _client.put>( + '$_prefix/user', + data: {'content': content.toJson()}, + ); + return UserMemoryContent.fromJson(response.data); + } + + Future updateWorkMemory( + WorkProfileContent content, + ) async { + final response = await _client.put>( + '$_prefix/work', + data: {'content': content.toJson()}, + ); + return WorkProfileContent.fromJson(response.data); + } + + Future patchUserMemory( + Map content, + ) async { + final response = await _client.patch>( + '$_prefix/user', + data: {'content': content}, + ); + return UserMemoryContent.fromJson(response.data); + } + + Future patchWorkMemory( + Map content, + ) async { + final response = await _client.patch>( + '$_prefix/work', + data: {'content': content}, + ); + return WorkProfileContent.fromJson(response.data); } } diff --git a/apps/lib/features/settings/ui/screens/memory_screen.dart b/apps/lib/features/settings/ui/screens/memory_screen.dart index dd1375f..3c67433 100644 --- a/apps/lib/features/settings/ui/screens/memory_screen.dart +++ b/apps/lib/features/settings/ui/screens/memory_screen.dart @@ -1,9 +1,15 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/app_toggle_switch.dart'; -import '../../data/services/memory_service.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/core/router/app_routes.dart'; +import 'package:social_app/shared/widgets/app_loading_indicator.dart'; +import 'package:social_app/shared/widgets/app_pressable.dart'; +import 'package:social_app/shared/widgets/toast/toast.dart'; +import 'package:social_app/shared/widgets/toast/toast_type.dart'; import '../widgets/settings_page_scaffold.dart'; +import '../../data/models/memory_models.dart'; +import '../../data/services/memory_service.dart'; class MemoryScreen extends StatefulWidget { const MemoryScreen({super.key}); @@ -13,14 +19,38 @@ class MemoryScreen extends StatefulWidget { } class _MemoryScreenState extends State { - bool _memoryEnabled = true; - final MemoryService _memoryService = MemoryService(); - late List _memoryItems; + final MemoryService _memoryService = sl(); + MemoryListResponse? _memoryData; + bool _isLoading = true; + String? _error; @override void initState() { super.initState(); - _memoryItems = _memoryService.getMemoryItems(); + _loadMemories(); + } + + Future _loadMemories() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final data = await _memoryService.getAllMemories(); + if (!mounted) return; + setState(() { + _memoryData = data; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '加载失败,请重试'; + _isLoading = false; + }); + } } @override @@ -28,18 +58,22 @@ class _MemoryScreenState extends State { return SettingsPageScaffold( title: '我的记忆', onBack: () => context.pop(), + footer: _buildFooter(), body: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildToggleCard(), - if (_memoryItems.isNotEmpty) ...[ - const SizedBox(height: 14), - _buildListTitle(), - const SizedBox(height: 8), - _buildMemoryList(), + const SizedBox(height: AppSpacing.lg), + if (_isLoading) ...[ + const SizedBox(height: AppSpacing.xxl), + _buildLoadingState(), + ] else if (_error != null) ...[ + const SizedBox(height: AppSpacing.xxl), + _buildErrorState(), + ] else ...[ + const SizedBox(height: AppSpacing.sm), + _buildMemoryCards(), ], - const SizedBox(height: 20), - _buildManageButton(), ], ), ); @@ -47,42 +81,122 @@ class _MemoryScreenState extends State { Widget _buildToggleCard() { return Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderSecondary), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.white, AppColors.surfaceInfoLight], + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderTertiary), + boxShadow: [ + BoxShadow( + color: AppColors.blue100.withValues(alpha: 0.35), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '启用记忆', + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue100, AppColors.blue50], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.blue200.withValues(alpha: 0.45), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.auto_awesome, + size: 22, + color: AppColors.blue600, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '智能记忆', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + const SizedBox(height: 2), + Text( + '持续学习你的偏好和习惯', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildLoadingState() { + return Center( + child: Container( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: const AppLoadingIndicator(size: 32), + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text( + _error ?? '加载失败', + style: TextStyle(fontSize: 14, color: AppColors.slate500), + ), + const SizedBox(height: AppSpacing.lg), + AppPressable( + onTap: _loadMemories, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.blue100), + ), + child: const Text( + '重新加载', style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.slate900, + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.blue600, ), ), - _buildToggle(_memoryEnabled, (v) { - setState(() => _memoryEnabled = v); - }), - ], - ), - const SizedBox(height: 10), - const Align( - alignment: Alignment.centerLeft, - child: Text( - '开启后,将持续记录并更新你的长期偏好', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - color: Color(0xFF71839F), - ), ), ), ], @@ -90,122 +204,318 @@ class _MemoryScreenState extends State { ); } - Widget _buildListTitle() { - return const Text( - '记忆条目', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.slate500, + Widget _buildMemoryCards() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionLabel('用户记忆'), + const SizedBox(height: AppSpacing.sm), + _buildUserMemoryCard(), + const SizedBox(height: AppSpacing.md), + _buildSectionLabel('工作记忆'), + const SizedBox(height: AppSpacing.sm), + _buildWorkMemoryCard(), + ], + ); + } + + Widget _buildSectionLabel(String label) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.slate500, + ), ), ); } - Widget _buildMemoryList() { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderSecondary), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (int i = 0; i < _memoryItems.length; i++) ...[ - _buildMemoryItem(_memoryItems[i]), - if (i < _memoryItems.length - 1) const SizedBox(height: 10), - ], - ], - ), - ); - } + Widget _buildUserMemoryCard() { + final userMemory = _memoryData?.userMemory; + final hasData = userMemory != null; - Widget _buildMemoryItem(MemoryItemModel item) { - return GestureDetector( - onTap: () {}, + return AppPressable( + onTap: () => context.push(AppRoutes.settingsMemoryUser), + borderRadius: BorderRadius.circular(AppRadius.xl), child: Container( - height: 74, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( - color: AppColors.surfaceTertiary, - borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.white, AppColors.surfaceInfoLight], + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderTertiary), + boxShadow: [ + BoxShadow( + color: AppColors.slate200.withValues(alpha: 0.45), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.blue100.withValues(alpha: 0.8), + AppColors.blue50.withValues(alpha: 0.8), + ], + ), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.person, + size: 20, + color: AppColors.blue600, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '个人偏好', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + const SizedBox(height: 2), + Text( + hasData ? userMemory.summary : '暂无信息', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.chevron_right, size: 20, color: AppColors.slate400), + ], + ), + if (hasData) ...[ + const SizedBox(height: AppSpacing.md), + _buildMemoryStatsRow( + icons: [ + Icons.people_outline, + Icons.place_outlined, + Icons.interests_outlined, + Icons.schedule_outlined, + ], + values: [ + '${userMemory.people.length}', + '${userMemory.places.length}', + '${userMemory.interests.length}', + '${userMemory.recurringRoutines.length}', + ], + labels: ['联系人', '地点', '兴趣', '日程'], + ), + ], + ], + ), + ), + ); + } + + Widget _buildWorkMemoryCard() { + final workMemory = _memoryData?.workMemory; + final hasData = workMemory != null; + + return AppPressable( + onTap: () => context.push(AppRoutes.settingsMemoryWork), + borderRadius: BorderRadius.circular(AppRadius.xl), + child: Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.white, AppColors.surfaceTertiary], + ), + borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.borderSecondary), + boxShadow: [ + BoxShadow( + color: AppColors.slate200.withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.violet500.withValues(alpha: 0.15), + AppColors.violet500.withValues(alpha: 0.05), + ], + ), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.work_outline, + size: 20, + color: AppColors.violet600, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '工作Profile', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + ), + const SizedBox(height: 2), + Text( + hasData ? workMemory.summary : '暂无信息', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.chevron_right, size: 20, color: AppColors.slate400), + ], + ), + if (hasData) ...[ + const SizedBox(height: AppSpacing.md), + _buildMemoryStatsRow( + icons: [ + Icons.psychology_outlined, + Icons.build_outlined, + Icons.folder_outlined, + Icons.groups_outlined, + ], + values: [ + '${workMemory.expertise.length}', + '${workMemory.preferredTools.length}', + '${workMemory.currentProjects.length}', + '${workMemory.teamMembers.length}', + ], + labels: ['专长', '工具', '项目', '团队'], + ), + ], + ], + ), + ), + ); + } + + Widget _buildMemoryStatsRow({ + required List icons, + required List values, + required List labels, + }) { + return Row( + children: List.generate(icons.length, (index) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + margin: EdgeInsets.only( + right: index < icons.length - 1 ? AppSpacing.sm : 0, + ), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + children: [ + Icon(icons[index], size: 16, color: AppColors.slate400), + const SizedBox(height: 4), + Text( + values[index], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const SizedBox(height: 2), + Text( + labels[index], + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: AppColors.slate400, + ), + ), + ], + ), + ), + ); + }), + ); + } + + Widget? _buildFooter() { + return AppPressable( + onTap: () { + Toast.show(context, '记忆会随着你的使用自动完善', type: ToastType.info); + }, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceInfoLight, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.borderQuaternary), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.surfaceInfo, - borderRadius: BorderRadius.circular(10), + Icon(Icons.info_outline, size: 16, color: AppColors.blue500), + const SizedBox(width: AppSpacing.sm), + const Text( + '点击卡片查看或编辑详情', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.blue600, ), - child: Icon(item.icon, size: 16, color: AppColors.blue500), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - item.title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate900, - ), - ), - const SizedBox(height: 4), - Text( - item.subtitle, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - color: AppColors.slate500, - ), - ), - ], - ), - ), - const Icon( - Icons.chevron_right, - size: 16, - color: AppColors.slate400, ), ], ), ), ); } - - Widget _buildManageButton() { - return GestureDetector( - onTap: () {}, - child: Container( - height: 44, - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderSecondary), - ), - child: const Center( - child: Text( - '管理记忆条目', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate700, - ), - ), - ), - ), - ); - } - - Widget _buildToggle(bool value, ValueChanged onChanged) { - return AppToggleSwitch(value: value, onChanged: onChanged); - } } diff --git a/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart b/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart new file mode 100644 index 0000000..fc6174a --- /dev/null +++ b/apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart @@ -0,0 +1,942 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/app_loading_indicator.dart'; +import 'package:social_app/shared/widgets/app_pressable.dart'; +import 'package:social_app/shared/widgets/toast/toast.dart'; +import 'package:social_app/shared/widgets/toast/toast_type.dart'; +import '../widgets/settings_page_scaffold.dart'; +import '../../data/models/memory_models.dart'; +import '../../data/services/memory_service.dart'; + +class UserMemoryDetailScreen extends StatefulWidget { + const UserMemoryDetailScreen({super.key}); + + @override + State createState() => _UserMemoryDetailScreenState(); +} + +class _UserMemoryDetailScreenState extends State { + final MemoryService _memoryService = sl(); + UserMemoryContent? _memory; + bool _isLoading = true; + bool _isSaving = false; + String? _error; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadMemory(); + } + + Future _loadMemory() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final memory = await _memoryService.getUserMemory(); + if (!mounted) return; + setState(() { + _memory = memory; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '加载失败'; + _isLoading = false; + }); + } + } + + Future _saveMemory() async { + if (_memory == null || !_hasChanges) return; + + if (!mounted) return; + setState(() { + _isSaving = true; + }); + + try { + await _memoryService.updateUserMemory(_memory!); + if (!mounted) return; + setState(() { + _isSaving = false; + _hasChanges = false; + }); + Toast.show(context, '保存成功', type: ToastType.success); + } catch (e) { + if (!mounted) return; + setState(() { + _isSaving = false; + }); + Toast.show(context, '保存失败', type: ToastType.error); + } + } + + void _updateMemory(UserMemoryContent newMemory) { + setState(() { + _memory = newMemory; + _hasChanges = true; + }); + } + + @override + Widget build(BuildContext context) { + return SettingsPageScaffold( + title: '个人偏好', + onBack: () => context.pop(), + footer: _hasChanges ? _buildSaveButton() : null, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoading) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildLoadingState(), + ] else if (_error != null) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildErrorState(), + ] else if (_memory != null) ...[ + _buildContent(), + ] else ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildEmptyState(), + ], + ], + ), + ); + } + + Widget _buildLoadingState() { + return const Center(child: AppLoadingIndicator(size: 32)); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text(_error ?? '加载失败', style: TextStyle(color: AppColors.slate500)), + const SizedBox(height: AppSpacing.lg), + AppPressable( + onTap: _loadMemory, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.blue100), + ), + child: const Text( + '重新加载', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.blue600, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person_off_outlined, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text('暂无个人偏好信息', style: TextStyle(color: AppColors.slate500)), + ], + ), + ); + } + + Widget _buildSaveButton() { + return SizedBox( + width: double.infinity, + height: 52, + child: AppPressable( + onTap: _isSaving ? null : _saveMemory, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Container( + height: 52, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.blue500, AppColors.blue600], + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + boxShadow: [ + BoxShadow( + color: const Color(0x4D60A5FA), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppColors.white, + ), + ), + ) + : const Text( + '保存更改', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.white, + ), + ), + ), + ), + ), + ); + } + + Widget _buildContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBasicInfoSection(), + const SizedBox(height: AppSpacing.lg), + _buildPeopleSection(), + const SizedBox(height: AppSpacing.lg), + _buildPlacesSection(), + const SizedBox(height: AppSpacing.lg), + _buildPreferencesSection(), + const SizedBox(height: AppSpacing.lg), + _buildInterestsSection(), + const SizedBox(height: AppSpacing.lg), + _buildAvoidTopicsSection(), + const SizedBox(height: AppSpacing.lg), + _buildRecurringRoutinesSection(), + const SizedBox(height: AppSpacing.xxl), + ], + ); + } + + Widget _buildBasicInfoSection() { + return _buildSection( + title: '基本信息', + icon: Icons.person_outline, + children: [ + _buildEditField( + label: '职业', + value: _memory?.occupation, + onChanged: (value) => + _updateMemory(_memory!.copyWith(occupation: value)), + ), + _buildEditField( + label: '时区', + value: _memory?.timezone, + onChanged: (value) => + _updateMemory(_memory!.copyWith(timezone: value)), + ), + _buildEditField( + label: '主要语言', + value: _memory?.primaryLanguage, + onChanged: (value) => + _updateMemory(_memory!.copyWith(primaryLanguage: value)), + ), + ], + ); + } + + Widget _buildPeopleSection() { + return _buildSection( + title: '联系人', + icon: Icons.people_outline, + count: _memory?.people.length ?? 0, + children: [ + if (_memory?.people.isEmpty ?? true) + _buildEmptySection('暂无联系人') + else + ..._memory!.people.asMap().entries.map((entry) { + final index = entry.key; + final person = entry.value; + return _buildPersonItem(person, index); + }), + const SizedBox(height: AppSpacing.sm), + _buildAddButton('添加联系人', () { + final newPeople = List.from(_memory!.people) + ..add(Person(name: '新联系人')); + _updateMemory(_memory!.copyWith(people: newPeople)); + }), + ], + ); + } + + Widget _buildPersonItem(Person person, int index) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildEditField( + label: '姓名', + value: person.name, + onChanged: (value) { + final newPeople = List.from(_memory!.people); + newPeople[index] = person.copyWith(name: value); + _updateMemory(_memory!.copyWith(people: newPeople)); + }, + ), + ), + AppPressable( + onTap: () { + final newPeople = List.from(_memory!.people) + ..removeAt(index); + _updateMemory(_memory!.copyWith(people: newPeople)); + }, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + padding: const EdgeInsets.all(AppSpacing.xs), + child: Icon(Icons.close, size: 18, color: AppColors.slate400), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _buildEditField( + label: '关系', + value: person.relationship, + onChanged: (value) { + final newPeople = List.from(_memory!.people); + newPeople[index] = person.copyWith(relationship: value); + _updateMemory(_memory!.copyWith(people: newPeople)); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _buildEditField( + label: '角色', + value: person.role, + onChanged: (value) { + final newPeople = List.from(_memory!.people); + newPeople[index] = person.copyWith(role: value); + _updateMemory(_memory!.copyWith(people: newPeople)); + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '联系方式', + value: person.preferredContactChannel, + onChanged: (value) { + final newPeople = List.from(_memory!.people); + newPeople[index] = person.copyWith( + preferredContactChannel: value, + ); + _updateMemory(_memory!.copyWith(people: newPeople)); + }, + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '备注', + value: person.notes, + onChanged: (value) { + final newPeople = List.from(_memory!.people); + newPeople[index] = person.copyWith(notes: value); + _updateMemory(_memory!.copyWith(people: newPeople)); + }, + ), + ], + ), + ); + } + + Widget _buildPlacesSection() { + return _buildSection( + title: '地点', + icon: Icons.place_outlined, + count: _memory?.places.length ?? 0, + children: [ + if (_memory?.places.isEmpty ?? true) + _buildEmptySection('暂无地点') + else + ..._memory!.places.asMap().entries.map((entry) { + final index = entry.key; + final place = entry.value; + return _buildPlaceItem(place, index); + }), + const SizedBox(height: AppSpacing.sm), + _buildAddButton('添加地点', () { + final newPlaces = List.from(_memory!.places) + ..add(Place(name: '新地点')); + _updateMemory(_memory!.copyWith(places: newPlaces)); + }), + ], + ); + } + + Widget _buildPlaceItem(Place place, int index) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildEditField( + label: '名称', + value: place.name, + onChanged: (value) { + final newPlaces = List.from(_memory!.places); + newPlaces[index] = place.copyWith(name: value); + _updateMemory(_memory!.copyWith(places: newPlaces)); + }, + ), + ), + AppPressable( + onTap: () { + final newPlaces = List.from(_memory!.places) + ..removeAt(index); + _updateMemory(_memory!.copyWith(places: newPlaces)); + }, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + padding: const EdgeInsets.all(AppSpacing.xs), + child: Icon(Icons.close, size: 18, color: AppColors.slate400), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _buildEditField( + label: '类别', + value: place.category, + onChanged: (value) { + final newPlaces = List.from(_memory!.places); + newPlaces[index] = place.copyWith(category: value); + _updateMemory(_memory!.copyWith(places: newPlaces)); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _buildEditField( + label: '偏好', + value: place.preference, + onChanged: (value) { + final newPlaces = List.from(_memory!.places); + newPlaces[index] = place.copyWith(preference: value); + _updateMemory(_memory!.copyWith(places: newPlaces)); + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '地址', + value: place.address, + onChanged: (value) { + final newPlaces = List.from(_memory!.places); + newPlaces[index] = place.copyWith(address: value); + _updateMemory(_memory!.copyWith(places: newPlaces)); + }, + ), + ], + ), + ); + } + + Widget _buildPreferencesSection() { + final prefs = _memory!.preferences; + return _buildSection( + title: '偏好设置', + icon: Icons.settings_outlined, + children: [ + _buildEditField( + label: '沟通风格', + value: prefs.communicationStyle, + onChanged: (value) { + _updateMemory( + _memory!.copyWith( + preferences: prefs.copyWith(communicationStyle: value), + ), + ); + }, + ), + _buildEditField( + label: '位置偏好', + value: prefs.locationPreference, + onChanged: (value) { + _updateMemory( + _memory!.copyWith( + preferences: prefs.copyWith(locationPreference: value), + ), + ); + }, + ), + _buildEditField( + label: '工作生活方式', + value: prefs.workLifestyle, + onChanged: (value) { + _updateMemory( + _memory!.copyWith( + preferences: prefs.copyWith(workLifestyle: value), + ), + ); + }, + ), + ], + ); + } + + Widget _buildInterestsSection() { + return _buildSection( + title: '兴趣', + icon: Icons.interests_outlined, + children: [ + _buildTagsSection( + tags: _memory?.interests ?? [], + onAdd: (tag) { + _updateMemory( + _memory!.copyWith(interests: [..._memory!.interests, tag]), + ); + }, + onRemove: (index) { + final newInterests = List.from(_memory!.interests) + ..removeAt(index); + _updateMemory(_memory!.copyWith(interests: newInterests)); + }, + ), + ], + ); + } + + Widget _buildAvoidTopicsSection() { + return _buildSection( + title: '回避话题', + icon: Icons.not_interested_outlined, + children: [ + _buildTagsSection( + tags: _memory?.avoidTopics ?? [], + onAdd: (tag) { + _updateMemory( + _memory!.copyWith(avoidTopics: [..._memory!.avoidTopics, tag]), + ); + }, + onRemove: (index) { + final newTopics = List.from(_memory!.avoidTopics) + ..removeAt(index); + _updateMemory(_memory!.copyWith(avoidTopics: newTopics)); + }, + ), + ], + ); + } + + Widget _buildRecurringRoutinesSection() { + return _buildSection( + title: '周期习惯', + icon: Icons.schedule_outlined, + count: _memory?.recurringRoutines.length ?? 0, + children: [ + if (_memory?.recurringRoutines.isEmpty ?? true) + _buildEmptySection('暂无周期习惯') + else + ..._memory!.recurringRoutines.asMap().entries.map((entry) { + final index = entry.key; + final routine = entry.value; + return _buildRoutineItem(routine, index); + }), + const SizedBox(height: AppSpacing.sm), + _buildAddButton('添加习惯', () { + final newRoutines = List.from( + _memory!.recurringRoutines, + )..add(RecurringRoutine(name: '新习惯')); + _updateMemory(_memory!.copyWith(recurringRoutines: newRoutines)); + }), + ], + ); + } + + Widget _buildRoutineItem(RecurringRoutine routine, int index) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildEditField( + label: '名称', + value: routine.name, + onChanged: (value) { + final newRoutines = List.from( + _memory!.recurringRoutines, + ); + newRoutines[index] = routine.copyWith(name: value); + _updateMemory( + _memory!.copyWith(recurringRoutines: newRoutines), + ); + }, + ), + ), + AppPressable( + onTap: () { + final newRoutines = List.from( + _memory!.recurringRoutines, + )..removeAt(index); + _updateMemory( + _memory!.copyWith(recurringRoutines: newRoutines), + ); + }, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + padding: const EdgeInsets.all(AppSpacing.xs), + child: Icon(Icons.close, size: 18, color: AppColors.slate400), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _buildEditField( + label: '描述', + value: routine.description, + onChanged: (value) { + final newRoutines = List.from( + _memory!.recurringRoutines, + ); + newRoutines[index] = routine.copyWith(description: value); + _updateMemory( + _memory!.copyWith(recurringRoutines: newRoutines), + ); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _buildEditField( + label: '周期', + value: routine.cadence, + onChanged: (value) { + final newRoutines = List.from( + _memory!.recurringRoutines, + ); + newRoutines[index] = routine.copyWith(cadence: value); + _updateMemory( + _memory!.copyWith(recurringRoutines: newRoutines), + ); + }, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSection({ + required String title, + required IconData icon, + int? count, + required List children, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: AppColors.blue500), + const SizedBox(width: AppSpacing.sm), + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.slate800, + ), + ), + if (count != null) ...[ + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + '$count', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.blue600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.md), + ...children, + ], + ); + } + + Widget _buildEditField({ + required String label, + String? value, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + const SizedBox(height: AppSpacing.xs), + Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: TextFormField( + initialValue: value, + onChanged: onChanged, + style: const TextStyle(fontSize: 14, color: AppColors.slate800), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + border: InputBorder.none, + hintText: '输入$label', + hintStyle: TextStyle(color: AppColors.slate400, fontSize: 14), + ), + ), + ), + ], + ); + } + + Widget _buildEmptySection(String message) { + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Center( + child: Text( + message, + style: TextStyle(fontSize: 14, color: AppColors.slate400), + ), + ), + ); + } + + Widget _buildTagsSection({ + required List tags, + required ValueChanged onAdd, + required ValueChanged onRemove, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + ...tags.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.blue100), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: const TextStyle( + fontSize: 13, + color: AppColors.blue600, + ), + ), + const SizedBox(width: AppSpacing.xs), + AppPressable( + onTap: () => onRemove(entry.key), + borderRadius: BorderRadius.circular(AppRadius.full), + child: Icon( + Icons.close, + size: 14, + color: AppColors.blue400, + ), + ), + ], + ), + ); + }), + AppPressable( + onTap: () => _showAddTagDialog(onAdd), + borderRadius: BorderRadius.circular(AppRadius.full), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.borderSecondary, + style: BorderStyle.solid, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: AppColors.slate500), + const SizedBox(width: AppSpacing.xs), + Text( + '添加', + style: TextStyle(fontSize: 13, color: AppColors.slate500), + ), + ], + ), + ), + ), + ], + ), + ], + ); + } + + void _showAddTagDialog(ValueChanged onAdd) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('添加'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: '输入内容'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + if (controller.text.isNotEmpty) { + onAdd(controller.text); + } + Navigator.pop(context); + }, + child: const Text('添加'), + ), + ], + ), + ); + } + + Widget _buildAddButton(String text, VoidCallback onTap) { + return AppPressable( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.borderSecondary, + style: BorderStyle.solid, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, size: 18, color: AppColors.blue500), + const SizedBox(width: AppSpacing.xs), + Text( + text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.blue600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart b/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart new file mode 100644 index 0000000..cb2c5b1 --- /dev/null +++ b/apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart @@ -0,0 +1,889 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/app_loading_indicator.dart'; +import 'package:social_app/shared/widgets/app_pressable.dart'; +import 'package:social_app/shared/widgets/toast/toast.dart'; +import 'package:social_app/shared/widgets/toast/toast_type.dart'; +import '../widgets/settings_page_scaffold.dart'; +import '../../data/models/memory_models.dart'; +import '../../data/services/memory_service.dart'; + +class WorkMemoryDetailScreen extends StatefulWidget { + const WorkMemoryDetailScreen({super.key}); + + @override + State createState() => _WorkMemoryDetailScreenState(); +} + +class _WorkMemoryDetailScreenState extends State { + final MemoryService _memoryService = sl(); + WorkProfileContent? _memory; + bool _isLoading = true; + bool _isSaving = false; + String? _error; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadMemory(); + } + + Future _loadMemory() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final memory = await _memoryService.getWorkMemory(); + if (!mounted) return; + setState(() { + _memory = memory; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '加载失败'; + _isLoading = false; + }); + } + } + + Future _saveMemory() async { + if (_memory == null || !_hasChanges) return; + + if (!mounted) return; + setState(() { + _isSaving = true; + }); + + try { + await _memoryService.updateWorkMemory(_memory!); + if (!mounted) return; + setState(() { + _isSaving = false; + _hasChanges = false; + }); + Toast.show(context, '保存成功', type: ToastType.success); + } catch (e) { + if (!mounted) return; + setState(() { + _isSaving = false; + }); + Toast.show(context, '保存失败', type: ToastType.error); + } + } + + void _updateMemory(WorkProfileContent newMemory) { + setState(() { + _memory = newMemory; + _hasChanges = true; + }); + } + + @override + Widget build(BuildContext context) { + return SettingsPageScaffold( + title: '工作Profile', + onBack: () => context.pop(), + footer: _hasChanges ? _buildSaveButton() : null, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoading) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildLoadingState(), + ] else if (_error != null) ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildErrorState(), + ] else if (_memory != null) ...[ + _buildContent(), + ] else ...[ + const SizedBox(height: AppSpacing.xxl * 2), + _buildEmptyState(), + ], + ], + ), + ); + } + + Widget _buildLoadingState() { + return const Center(child: AppLoadingIndicator(size: 32)); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text(_error ?? '加载失败', style: TextStyle(color: AppColors.slate500)), + const SizedBox(height: AppSpacing.lg), + AppPressable( + onTap: _loadMemory, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.blue100), + ), + child: const Text( + '重新加载', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.blue600, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.work_off_outlined, size: 48, color: AppColors.slate300), + const SizedBox(height: AppSpacing.md), + Text('暂无工作信息', style: TextStyle(color: AppColors.slate500)), + ], + ), + ); + } + + Widget _buildSaveButton() { + return SizedBox( + width: double.infinity, + height: 52, + child: AppPressable( + onTap: _isSaving ? null : _saveMemory, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Container( + height: 52, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.blue500, AppColors.blue600], + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + boxShadow: [ + BoxShadow( + color: const Color(0x4D60A5FA), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppColors.white, + ), + ), + ) + : const Text( + '保存更改', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.white, + ), + ), + ), + ), + ), + ); + } + + Widget _buildContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBasicInfoSection(), + const SizedBox(height: AppSpacing.lg), + _buildExpertiseSection(), + const SizedBox(height: AppSpacing.lg), + _buildPreferredToolsSection(), + const SizedBox(height: AppSpacing.lg), + _buildProjectsSection(), + const SizedBox(height: AppSpacing.lg), + _buildTeamMembersSection(), + const SizedBox(height: AppSpacing.lg), + _buildWorkHabitsSection(), + const SizedBox(height: AppSpacing.lg), + _buildTeamContextSection(), + const SizedBox(height: AppSpacing.lg), + _buildWorkRulesSection(), + const SizedBox(height: AppSpacing.xxl), + ], + ); + } + + Widget _buildBasicInfoSection() { + return _buildSection( + title: '基本信息', + icon: Icons.work_outline, + children: [ + _buildEditField( + label: '职业', + value: _memory?.occupation, + onChanged: (value) => + _updateMemory(_memory!.copyWith(occupation: value)), + ), + ], + ); + } + + Widget _buildExpertiseSection() { + return _buildSection( + title: '专长', + icon: Icons.psychology_outlined, + count: _memory?.expertise.length ?? 0, + children: [ + _buildTagsSection( + tags: _memory?.expertise ?? [], + onAdd: (tag) { + _updateMemory( + _memory!.copyWith(expertise: [..._memory!.expertise, tag]), + ); + }, + onRemove: (index) { + final newExpertise = List.from(_memory!.expertise) + ..removeAt(index); + _updateMemory(_memory!.copyWith(expertise: newExpertise)); + }, + ), + ], + ); + } + + Widget _buildPreferredToolsSection() { + return _buildSection( + title: '偏好工具', + icon: Icons.build_outlined, + count: _memory?.preferredTools.length ?? 0, + children: [ + _buildTagsSection( + tags: _memory?.preferredTools ?? [], + onAdd: (tag) { + _updateMemory( + _memory!.copyWith( + preferredTools: [..._memory!.preferredTools, tag], + ), + ); + }, + onRemove: (index) { + final newTools = List.from(_memory!.preferredTools) + ..removeAt(index); + _updateMemory(_memory!.copyWith(preferredTools: newTools)); + }, + ), + ], + ); + } + + Widget _buildProjectsSection() { + return _buildSection( + title: '当前项目', + icon: Icons.folder_outlined, + count: _memory?.currentProjects.length ?? 0, + children: [ + if (_memory?.currentProjects.isEmpty ?? true) + _buildEmptySection('暂无项目') + else + ..._memory!.currentProjects.asMap().entries.map((entry) { + final index = entry.key; + final project = entry.value; + return _buildProjectItem(project, index); + }), + const SizedBox(height: AppSpacing.sm), + _buildAddButton('添加项目', () { + final newProjects = List.from( + _memory!.currentProjects, + )..add(CurrentProject(name: '新项目')); + _updateMemory(_memory!.copyWith(currentProjects: newProjects)); + }), + ], + ); + } + + Widget _buildProjectItem(CurrentProject project, int index) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildEditField( + label: '项目名称', + value: project.name, + onChanged: (value) { + final newProjects = List.from( + _memory!.currentProjects, + ); + newProjects[index] = project.copyWith(name: value); + _updateMemory( + _memory!.copyWith(currentProjects: newProjects), + ); + }, + ), + ), + AppPressable( + onTap: () { + final newProjects = List.from( + _memory!.currentProjects, + )..removeAt(index); + _updateMemory( + _memory!.copyWith(currentProjects: newProjects), + ); + }, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + padding: const EdgeInsets.all(AppSpacing.xs), + child: Icon(Icons.close, size: 18, color: AppColors.slate400), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _buildEditField( + label: '状态', + value: project.status, + onChanged: (value) { + final newProjects = List.from( + _memory!.currentProjects, + ); + newProjects[index] = project.copyWith(status: value); + _updateMemory( + _memory!.copyWith(currentProjects: newProjects), + ); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _buildEditField( + label: '优先级', + value: project.priority, + onChanged: (value) { + final newProjects = List.from( + _memory!.currentProjects, + ); + newProjects[index] = project.copyWith(priority: value); + _updateMemory( + _memory!.copyWith(currentProjects: newProjects), + ); + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '描述', + value: project.description, + onChanged: (value) { + final newProjects = List.from( + _memory!.currentProjects, + ); + newProjects[index] = project.copyWith(description: value); + _updateMemory(_memory!.copyWith(currentProjects: newProjects)); + }, + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '截止日期', + value: project.deadline?.toIso8601String().split('T').first, + onChanged: (value) { + final newProjects = List.from( + _memory!.currentProjects, + ); + newProjects[index] = project.copyWith( + deadline: value.isNotEmpty ? DateTime.tryParse(value) : null, + ); + _updateMemory(_memory!.copyWith(currentProjects: newProjects)); + }, + ), + ], + ), + ); + } + + Widget _buildTeamMembersSection() { + return _buildSection( + title: '团队成员', + icon: Icons.groups_outlined, + count: _memory?.teamMembers.length ?? 0, + children: [ + if (_memory?.teamMembers.isEmpty ?? true) + _buildEmptySection('暂无团队成员') + else + ..._memory!.teamMembers.asMap().entries.map((entry) { + final index = entry.key; + final member = entry.value; + return _buildTeamMemberItem(member, index); + }), + const SizedBox(height: AppSpacing.sm), + _buildAddButton('添加成员', () { + final newMembers = List.from(_memory!.teamMembers) + ..add(TeamMember(name: '新成员')); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }), + ], + ); + } + + Widget _buildTeamMemberItem(TeamMember member, int index) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildEditField( + label: '姓名', + value: member.name, + onChanged: (value) { + final newMembers = List.from( + _memory!.teamMembers, + ); + newMembers[index] = member.copyWith(name: value); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }, + ), + ), + AppPressable( + onTap: () { + final newMembers = List.from(_memory!.teamMembers) + ..removeAt(index); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + padding: const EdgeInsets.all(AppSpacing.xs), + child: Icon(Icons.close, size: 18, color: AppColors.slate400), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: _buildEditField( + label: '角色', + value: member.role, + onChanged: (value) { + final newMembers = List.from( + _memory!.teamMembers, + ); + newMembers[index] = member.copyWith(role: value); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _buildEditField( + label: '关系', + value: member.relationship, + onChanged: (value) { + final newMembers = List.from( + _memory!.teamMembers, + ); + newMembers[index] = member.copyWith(relationship: value); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '联系方式', + value: member.preferredContactChannel, + onChanged: (value) { + final newMembers = List.from(_memory!.teamMembers); + newMembers[index] = member.copyWith( + preferredContactChannel: value, + ); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }, + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '备注', + value: member.notes, + onChanged: (value) { + final newMembers = List.from(_memory!.teamMembers); + newMembers[index] = member.copyWith(notes: value); + _updateMemory(_memory!.copyWith(teamMembers: newMembers)); + }, + ), + ], + ), + ); + } + + Widget _buildWorkHabitsSection() { + final habits = _memory!.workHabits; + return _buildSection( + title: '工作习惯', + icon: Icons.schedule_outlined, + children: [ + _buildEditField( + label: '通知渠道', + value: habits.notificationChannel, + onChanged: (value) { + _updateMemory( + _memory!.copyWith( + workHabits: habits.copyWith(notificationChannel: value), + ), + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + _buildEditField( + label: '备注', + value: habits.notes, + onChanged: (value) { + _updateMemory( + _memory!.copyWith(workHabits: habits.copyWith(notes: value)), + ); + }, + ), + ], + ); + } + + Widget _buildTeamContextSection() { + return _buildSection( + title: '团队背景', + icon: Icons.business_outlined, + children: [ + _buildEditField( + label: '团队背景描述', + value: _memory?.teamContext, + onChanged: (value) => + _updateMemory(_memory!.copyWith(teamContext: value)), + ), + ], + ); + } + + Widget _buildWorkRulesSection() { + return _buildSection( + title: '工作规则', + icon: Icons.rule_outlined, + children: [ + _buildTagsSection( + tags: _memory?.workRules ?? [], + onAdd: (tag) { + _updateMemory( + _memory!.copyWith(workRules: [..._memory!.workRules, tag]), + ); + }, + onRemove: (index) { + final newRules = List.from(_memory!.workRules) + ..removeAt(index); + _updateMemory(_memory!.copyWith(workRules: newRules)); + }, + ), + ], + ); + } + + Widget _buildSection({ + required String title, + required IconData icon, + int? count, + required List children, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: AppColors.violet500), + const SizedBox(width: AppSpacing.sm), + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.slate800, + ), + ), + if (count != null) ...[ + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.violet500.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.violet600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.md), + ...children, + ], + ); + } + + Widget _buildEditField({ + required String label, + String? value, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + const SizedBox(height: AppSpacing.xs), + Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: TextFormField( + initialValue: value, + onChanged: onChanged, + style: const TextStyle(fontSize: 14, color: AppColors.slate800), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + border: InputBorder.none, + hintText: '输入$label', + hintStyle: TextStyle(color: AppColors.slate400, fontSize: 14), + ), + ), + ), + ], + ); + } + + Widget _buildEmptySection(String message) { + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: Center( + child: Text( + message, + style: TextStyle(fontSize: 14, color: AppColors.slate400), + ), + ), + ); + } + + Widget _buildTagsSection({ + required List tags, + required ValueChanged onAdd, + required ValueChanged onRemove, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + ...tags.asMap().entries.map((entry) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.violet500.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.violet500.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + entry.value, + style: TextStyle( + fontSize: 13, + color: AppColors.violet600, + ), + ), + const SizedBox(width: AppSpacing.xs), + AppPressable( + onTap: () => onRemove(entry.key), + borderRadius: BorderRadius.circular(AppRadius.full), + child: Icon( + Icons.close, + size: 14, + color: AppColors.violet500, + ), + ), + ], + ), + ); + }), + AppPressable( + onTap: () => _showAddTagDialog(onAdd), + borderRadius: BorderRadius.circular(AppRadius.full), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: AppColors.borderSecondary, + style: BorderStyle.solid, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: AppColors.slate500), + const SizedBox(width: AppSpacing.xs), + Text( + '添加', + style: TextStyle(fontSize: 13, color: AppColors.slate500), + ), + ], + ), + ), + ), + ], + ), + ], + ); + } + + void _showAddTagDialog(ValueChanged onAdd) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('添加'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: '输入内容'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + if (controller.text.isNotEmpty) { + onAdd(controller.text); + } + Navigator.pop(context); + }, + child: const Text('添加'), + ), + ], + ), + ); + } + + Widget _buildAddButton(String text, VoidCallback onTap) { + return AppPressable( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: AppColors.borderSecondary, + style: BorderStyle.solid, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, size: 18, color: AppColors.violet500), + const SizedBox(width: AppSpacing.xs), + Text( + text, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.violet600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/test/features/chat/ag_ui_event_test.dart b/apps/test/features/chat/ag_ui_event_test.dart index 811c824..fd1b16a 100644 --- a/apps/test/features/chat/ag_ui_event_test.dart +++ b/apps/test/features/chat/ag_ui_event_test.dart @@ -45,7 +45,7 @@ void main() { expect(result.toolCallId, 'call_1'); expect(result.toolName, 'calendar_read'); expect(result.resultSummary, '找到 2 条结果'); - expect(result.uiSchema, isNull); + expect(result.status, 'success'); }); test('parses history snapshot with ui_schema', () { diff --git a/apps/test/features/chat/data/services/ag_ui_service_test.dart b/apps/test/features/chat/data/services/ag_ui_service_test.dart index b95371d..1f36593 100644 --- a/apps/test/features/chat/data/services/ag_ui_service_test.dart +++ b/apps/test/features/chat/data/services/ag_ui_service_test.dart @@ -44,6 +44,11 @@ class _FakeApiClient implements IApiClient { throw UnimplementedError(); } + @override + Future> put(String path, {data, Options? options}) { + throw UnimplementedError(); + } + @override Future> post(String path, {data, Options? options}) async { final runIdFactory = this.runIdFactory; diff --git a/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart b/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart index 0e6f9b8..d2d18e8 100644 --- a/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart +++ b/apps/test/features/chat/presentation/chat_bloc_attachment_sync_test.dart @@ -33,6 +33,11 @@ class _NoopApiClient implements IApiClient { throw UnimplementedError(); } + @override + Future> put(String path, {data, Options? options}) { + throw UnimplementedError(); + } + @override Future> post(String path, {data, Options? options}) { throw UnimplementedError(); @@ -152,7 +157,6 @@ void main() { toolName: 'ocr_image', resultSummary: 'done', status: 'success', - uiSchema: null, ), ); toolItem = bloc.state.items.last as ToolCallItem; diff --git a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart index 4e3817e..9b9c6df 100644 --- a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart +++ b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart @@ -83,6 +83,11 @@ class _TestApiClient implements IApiClient { return Response(requestOptions: RequestOptions(path: path)); } + @override + Future> put(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } + @override Future> post(String path, {data, Options? options}) async { return Response(requestOptions: RequestOptions(path: path)); diff --git a/apps/test/features/settings/ui/screens/settings_screen_test.dart b/apps/test/features/settings/ui/screens/settings_screen_test.dart index 63e6604..abd1f57 100644 --- a/apps/test/features/settings/ui/screens/settings_screen_test.dart +++ b/apps/test/features/settings/ui/screens/settings_screen_test.dart @@ -41,6 +41,11 @@ class _TestApiClient implements IApiClient { Future> post(String path, {data, Options? options}) async { return Response(requestOptions: RequestOptions(path: path)); } + + @override + Future> put(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } } class _FakeUsersApi extends UsersApi { diff --git a/backend/alembic/versions/20260323_0001_drop_memories_source_column.py b/backend/alembic/versions/20260323_0001_drop_memories_source_column.py new file mode 100644 index 0000000..8b85639 --- /dev/null +++ b/backend/alembic/versions/20260323_0001_drop_memories_source_column.py @@ -0,0 +1,38 @@ +"""drop source column from memories + +Revision ID: 202603230001 +Revises: 202603200001 +Create Date: 2026-03-23 18:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "202603230001" +down_revision: Union[str, Sequence[str], None] = "202603200001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("memories")} + if "source" in columns: + op.drop_column("memories", "source") + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("memories")} + if "source" not in columns: + op.add_column( + "memories", + sa.Column( + "source", sa.String(length=20), nullable=False, server_default="agent" + ), + ) + op.alter_column("memories", "source", server_default=None) diff --git a/backend/alembic/versions/20260323_0002_drop_memories_title_column.py b/backend/alembic/versions/20260323_0002_drop_memories_title_column.py new file mode 100644 index 0000000..d7f03ba --- /dev/null +++ b/backend/alembic/versions/20260323_0002_drop_memories_title_column.py @@ -0,0 +1,34 @@ +"""drop title column from memories + +Revision ID: 202603230002 +Revises: 202603230001 +Create Date: 2026-03-23 21:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "202603230002" +down_revision: Union[str, Sequence[str], None] = "202603230001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("memories")} + if "title" in columns: + op.drop_column("memories", "title") + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column["name"] for column in inspector.get_columns("memories")} + if "title" not in columns: + op.add_column( + "memories", sa.Column("title", sa.String(length=255), nullable=True) + ) diff --git a/backend/alembic/versions/20260323_0003_bootstrap_job_key_and_unique_indexes.py b/backend/alembic/versions/20260323_0003_bootstrap_job_key_and_unique_indexes.py new file mode 100644 index 0000000..bdcfd6b --- /dev/null +++ b/backend/alembic/versions/20260323_0003_bootstrap_job_key_and_unique_indexes.py @@ -0,0 +1,355 @@ +"""add bootstrap key and unique indexes for registration bootstrap + +Revision ID: 202603230003 +Revises: 202603230002 +Create Date: 2026-03-23 23:10:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "202603230003" +down_revision: Union[str, Sequence[str], None] = "202603230002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + automation_columns = { + column["name"] for column in inspector.get_columns("automation_jobs") + } + if "bootstrap_key" not in automation_columns: + op.add_column( + "automation_jobs", + sa.Column("bootstrap_key", sa.String(length=64), nullable=True), + ) + + op.execute("DROP INDEX IF EXISTS ux_automation_jobs_owner_memory_active") + + op.execute( + """ + UPDATE public.automation_jobs + SET bootstrap_key = 'memory_extraction' + WHERE bootstrap_key IS NULL + AND ( + config->>'agent_type' = 'memory' + OR ( + created_by = owner_id + AND title = 'Memory Agent' + AND coalesce(config->'enabled_tools', '[]'::jsonb) @> '["memory.write", "memory.forget"]'::jsonb + AND jsonb_array_length(coalesce(config->'enabled_tools', '[]'::jsonb)) = 2 + AND coalesce(config->'context', '{}'::jsonb) @> jsonb_build_object( + 'source', 'latest_chat', + 'window_mode', 'day', + 'window_count', 2 + ) + ) + ) + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS public.memories_dedup_backup_202603230003 + (LIKE public.memories INCLUDING ALL) + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS public.automation_jobs_dedup_backup_202603230003 ( + id UUID PRIMARY KEY + ) + """ + ) + + op.execute( + """ + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY owner_id, bootstrap_key + ORDER BY updated_at DESC, created_at DESC, id DESC + ) AS rn + FROM public.automation_jobs + WHERE deleted_at IS NULL + AND bootstrap_key IS NOT NULL + ) + INSERT INTO public.automation_jobs_dedup_backup_202603230003(id) + SELECT id + FROM ranked + WHERE rn > 1 + ON CONFLICT (id) DO NOTHING + """ + ) + + op.execute( + """ + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY owner_id, memory_type + ORDER BY updated_at DESC, created_at DESC, id DESC + ) AS rn + FROM public.memories + ) + INSERT INTO public.memories_dedup_backup_202603230003 + SELECT m.* + FROM public.memories m + JOIN ranked r ON r.id = m.id + WHERE r.rn > 1 + ON CONFLICT (id) DO NOTHING + """ + ) + + op.execute( + """ + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY owner_id, bootstrap_key + ORDER BY updated_at DESC, created_at DESC, id DESC + ) AS rn + FROM public.automation_jobs + WHERE deleted_at IS NULL + AND bootstrap_key IS NOT NULL + ) + UPDATE public.automation_jobs aj + SET deleted_at = now() + FROM ranked r + WHERE aj.id = r.id + AND r.rn > 1 + """ + ) + + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS ux_automation_jobs_owner_bootstrap_key_active + ON public.automation_jobs(owner_id, bootstrap_key) + WHERE deleted_at IS NULL + AND bootstrap_key IS NOT NULL + """ + ) + + op.execute( + """ + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY owner_id, memory_type + ORDER BY updated_at DESC, created_at DESC, id DESC + ) AS rn + FROM public.memories + ) + DELETE FROM public.memories m + USING ranked r + WHERE m.id = r.id + AND r.rn > 1 + """ + ) + + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS ux_memories_owner_memory_type + ON public.memories(owner_id, memory_type) + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' + AS $$ + DECLARE + base_seed TEXT; + candidate_username TEXT; + attempt INT := 0; + BEGIN + base_seed := coalesce(NEW.phone, NEW.id::text); + + LOOP + candidate_username := 'user_' || public.generate_profile_username_suffix(base_seed || ':' || attempt::text); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.username = candidate_username + ); + attempt := attempt + 1; + IF attempt >= 50 THEN + candidate_username := 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 6); + EXIT; + END IF; + END LOOP; + + INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) + VALUES ( + NEW.id, + candidate_username, + NULL, + NULL, + '{}'::jsonb, + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + RETURN NEW; + END; + $$; + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ux_memories_owner_memory_type") + op.execute("DROP INDEX IF EXISTS ux_automation_jobs_owner_bootstrap_key_active") + + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names(schema="public")) + + if "automation_jobs_dedup_backup_202603230003" in tables: + op.execute( + """ + UPDATE public.automation_jobs aj + SET deleted_at = NULL + FROM public.automation_jobs_dedup_backup_202603230003 b + WHERE aj.id = b.id + """ + ) + op.execute( + "DROP TABLE IF EXISTS public.automation_jobs_dedup_backup_202603230003" + ) + + if "memories_dedup_backup_202603230003" in tables: + op.execute( + """ + INSERT INTO public.memories + SELECT b.* + FROM public.memories_dedup_backup_202603230003 b + LEFT JOIN public.memories m ON m.id = b.id + WHERE m.id IS NULL + ON CONFLICT (id) DO NOTHING + """ + ) + op.execute("DROP TABLE IF EXISTS public.memories_dedup_backup_202603230003") + + automation_columns = { + column["name"] for column in inspector.get_columns("automation_jobs") + } + if "bootstrap_key" in automation_columns: + op.drop_column("automation_jobs", "bootstrap_key") + + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS ux_automation_jobs_owner_memory_active + ON public.automation_jobs(owner_id) + WHERE deleted_at IS NULL + AND config->>'agent_type' = 'memory' + """ + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' + AS $$ + DECLARE + base_seed TEXT; + candidate_username TEXT; + attempt INT := 0; + BEGIN + base_seed := coalesce(NEW.phone, NEW.id::text); + + LOOP + candidate_username := 'user_' || public.generate_profile_username_suffix(base_seed || ':' || attempt::text); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.username = candidate_username + ); + attempt := attempt + 1; + IF attempt >= 50 THEN + candidate_username := 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 6); + EXIT; + END IF; + END LOOP; + + INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) + VALUES ( + NEW.id, + candidate_username, + NULL, + NULL, + '{}'::jsonb, + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM public.automation_jobs aj + WHERE aj.owner_id = NEW.id + AND aj.deleted_at IS NULL + AND aj.config->>'agent_type' = 'memory' + ) THEN + INSERT INTO public.automation_jobs ( + id, + owner_id, + title, + config, + schedule_type, + run_at, + next_run_at, + timezone, + status, + created_by, + created_at, + updated_at + ) VALUES ( + gen_random_uuid(), + NEW.id, + 'Memory Agent', + jsonb_build_object( + 'agent_type', 'memory', + 'model_code', 'qwen3.5-flash', + 'enabled_tools', jsonb_build_array('calendar.read', 'user.lookup'), + 'input_template', '请基于最近聊天上下文生成一段可执行的记忆总结与建议。', + 'context', jsonb_build_object( + 'source', 'latest_chat', + 'window_mode', 'day', + 'window_count', 2 + ) + ), + 'daily', + now(), + now() + interval '1 day', + 'UTC', + 'active', + NEW.id, + now(), + now() + ); + END IF; + EXCEPTION WHEN unique_violation THEN + NULL; + END; + + RETURN NEW; + END; + $$; + """ + ) diff --git a/backend/src/core/agentscope/prompts/__init__.py b/backend/src/core/agentscope/prompts/__init__.py index 45bee65..03652c2 100644 --- a/backend/src/core/agentscope/prompts/__init__.py +++ b/backend/src/core/agentscope/prompts/__init__.py @@ -1,11 +1,15 @@ from core.agentscope.prompts.agent_prompt import build_agent_prompt -from core.agentscope.prompts.memory_prompt import build_memory_prompt +from core.agentscope.prompts.memory_prompt import ( + build_user_memory_prompt, + build_work_memory_prompt, +) from core.agentscope.prompts.system_prompt import build_system_prompt from core.agentscope.prompts.tool_prompt import build_tools_prompt __all__ = [ "build_agent_prompt", - "build_memory_prompt", + "build_user_memory_prompt", + "build_work_memory_prompt", "build_system_prompt", "build_tools_prompt", ] diff --git a/backend/src/core/agentscope/prompts/memory_prompt.py b/backend/src/core/agentscope/prompts/memory_prompt.py index 638f3d9..20cd2d7 100644 --- a/backend/src/core/agentscope/prompts/memory_prompt.py +++ b/backend/src/core/agentscope/prompts/memory_prompt.py @@ -1,52 +1,59 @@ from __future__ import annotations import json -from typing import Any -from schemas.memories import MemoryContext, MemoryListResponse +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent def _wrap_section(section: str, content: str) -> str: marker_map = { - "memory": ("", ""), + "user_memory": ("", ""), + "work_memory": ("", ""), } start, end = marker_map[section] body = content.strip() return f"{start}\n{body}\n{end}" if body else f"{start}\n{end}" -def _format_memory_content(content: dict[str, Any]) -> str: +def _format_content(content: UserMemoryContent | WorkProfileContent) -> str: if isinstance(content, dict): return json.dumps(content, ensure_ascii=True, separators=(",", ":")) - return str(content) + return json.dumps( + content.model_dump(mode="json"), ensure_ascii=True, separators=(",", ":") + ) -def _format_memory(ctx: MemoryContext) -> str: - parts = [ - f"[{ctx.memory_type.value.upper()}] {ctx.title or 'Untitled'}", - f" source: {ctx.source.value}", - f" content: {_format_memory_content(ctx.content)}", - ] - if ctx.created_at: - parts.append(f" created_at: {ctx.created_at.isoformat()}") - return "\n".join(parts) - - -def build_memory_prompt( +def build_user_memory_prompt( *, - memories: MemoryListResponse, + user_memory: UserMemoryContent | None, ) -> str | None: - if not memories.memories: + if user_memory is None: return None lines: list[str] = [ - "[User Memories]", - "- Memories are persistent context from previous sessions.", - "- Use them to ground responses in known user facts and preferences.", - "- Do not invent facts not present in memories.", + "[User Memory]", + "- User memory contains personal preferences, habits, people, and places.", + "- Use this to understand the user's personal context and preferences.", + "- Do not invent facts not present here.", + f"content: {_format_content(user_memory)}", ] - for ctx in memories.memories: - lines.append(_format_memory(ctx)) + return _wrap_section("user_memory", "\n".join(lines)) - return _wrap_section("memory", "\n".join(lines)) + +def build_work_memory_prompt( + *, + work_memory: WorkProfileContent | None, +) -> str | None: + if work_memory is None: + return None + + lines: list[str] = [ + "[Work Memory]", + "- Work memory contains projects, team members, habits, and milestones.", + "- Use this to understand the user's work context and ongoing tasks.", + "- Do not invent facts not present here.", + f"content: {_format_content(work_memory)}", + ] + + return _wrap_section("work_memory", "\n".join(lines)) diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index 26b5522..6eac2d1 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -9,12 +9,15 @@ from ag_ui.core.types import Tool from core.agentscope.prompts.agent_prompt import ( build_agent_prompt, ) -from core.agentscope.prompts.memory_prompt import build_memory_prompt +from core.agentscope.prompts.memory_prompt import ( + build_user_memory_prompt, + build_work_memory_prompt, +) from core.agentscope.prompts.route_prompt import build_frontend_route_prompt from core.agentscope.prompts.tool_prompt import build_tools_prompt from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig from schemas.agent.forwarded_props import ClientTimeContext -from schemas.memories import MemoryListResponse +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent from schemas.user.context import UserContext @@ -210,9 +213,16 @@ def build_system_prompt( runtime_client_time: ClientTimeContext | None = None, extra_context: str | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None, - memories: MemoryListResponse | None = None, + user_memory: UserMemoryContent | None = None, + work_memory: WorkProfileContent | None = None, ) -> str: include_route_section = agent_type == AgentType.WORKER + + if agent_type == AgentType.ROUTER: + memory_prompt = build_user_memory_prompt(user_memory=user_memory) + else: + memory_prompt = build_work_memory_prompt(work_memory=work_memory) + sections: list[str | None] = [ _build_identity_section(), _build_env_section( @@ -228,7 +238,7 @@ def build_system_prompt( llm_config=llm_config, ), build_tools_prompt(tools=tools) if tools else None, - build_memory_prompt(memories=memories) if memories else None, + memory_prompt, _build_output_rules(), ] return "\n\n".join(item for item in sections if item).strip() diff --git a/backend/src/core/agentscope/runtime/orchestrator.py b/backend/src/core/agentscope/runtime/orchestrator.py index b08cc5a..edf00fd 100644 --- a/backend/src/core/agentscope/runtime/orchestrator.py +++ b/backend/src/core/agentscope/runtime/orchestrator.py @@ -7,7 +7,7 @@ from agentscope.message import Msg from core.agentscope.runtime.runner import AgentScopeRunner from core.logging import get_logger from schemas.automation import RuntimeConfig -from schemas.memories import MemoryListResponse +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent from schemas.user import UserContext logger = get_logger("core.agentscope.runtime.orchestrator") @@ -26,7 +26,8 @@ class RunnerLike(Protocol): pipeline: PipelineLike, run_input: RunAgentInput, runtime_config: RuntimeConfig, - memories: MemoryListResponse | None, + user_memory: UserMemoryContent | None, + work_memory: WorkProfileContent | None, ) -> dict[str, Any]: ... @@ -50,7 +51,8 @@ class AgentScopeRuntimeOrchestrator: context_messages: list[Msg], user_context: UserContext, runtime_config: RuntimeConfig, - memories: MemoryListResponse | None = None, + user_memory: UserMemoryContent | None = None, + work_memory: WorkProfileContent | None = None, ) -> dict[str, Any]: thread_id = run_input.thread_id run_id = run_input.run_id @@ -70,7 +72,8 @@ class AgentScopeRuntimeOrchestrator: pipeline=self._pipeline, run_input=run_input, runtime_config=runtime_config, - memories=memories, + user_memory=user_memory, + work_memory=work_memory, ) await self._pipeline.emit( diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 80f00be..e30a874 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -41,7 +41,7 @@ from schemas.agent.system_agent import ( SystemAgentLLMConfig, ) from schemas.automation import RuntimeConfig -from schemas.memories import MemoryListResponse +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent from schemas.user import UserContext from services.litellm.service import LiteLLMService from sqlalchemy import select @@ -71,7 +71,8 @@ class AgentScopeRunner: pipeline: PipelineLike, run_input: RunAgentInput, runtime_config: RuntimeConfig, - memories: MemoryListResponse | None = None, + user_memory: UserMemoryContent | None = None, + work_memory: WorkProfileContent | None = None, ) -> dict[str, Any]: owner_id = UUID(user_context.id) runtime_client_time = self._resolve_runtime_client_time(run_input=run_input) @@ -98,7 +99,7 @@ class AgentScopeRunner: context_messages=context_messages, stage_config=router_config, runtime_client_time=runtime_client_time, - memories=memories, + user_memory=user_memory, ) worker_output = await self._execute_worker_step( pipeline=pipeline, @@ -108,7 +109,7 @@ class AgentScopeRunner: toolkit=worker_toolkit, stage_config=worker_config, runtime_client_time=runtime_client_time, - memories=memories, + work_memory=work_memory, ) return { "router": router_output.model_dump(mode="json", exclude_none=True), @@ -166,7 +167,7 @@ class AgentScopeRunner: context_messages: list[Msg], stage_config: SystemAgentRuntimeConfig, runtime_client_time: ClientTimeContext | None, - memories: MemoryListResponse | None, + user_memory: UserMemoryContent | None, ) -> RouterAgentOutput: await self._emit_step_event( pipeline=pipeline, @@ -179,7 +180,7 @@ class AgentScopeRunner: context_messages=context_messages, stage_config=stage_config, runtime_client_time=runtime_client_time, - memories=memories, + user_memory=user_memory, run_input=run_input, ) router_output = RouterAgentOutput.model_validate(router_result.payload) @@ -201,7 +202,7 @@ class AgentScopeRunner: toolkit: Any, stage_config: SystemAgentRuntimeConfig, runtime_client_time: ClientTimeContext | None, - memories: MemoryListResponse | None, + work_memory: WorkProfileContent | None, ) -> WorkerAgentOutputLite: worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode) await self._emit_step_event( @@ -221,7 +222,7 @@ class AgentScopeRunner: worker_output_model=worker_output_model, pipeline=pipeline, runtime_client_time=runtime_client_time, - memories=memories, + work_memory=work_memory, ) worker_output = worker_output_model.model_validate(worker_result.payload) await self._emit_step_event( @@ -239,7 +240,7 @@ class AgentScopeRunner: context_messages: list[Msg], stage_config: SystemAgentRuntimeConfig, runtime_client_time: ClientTimeContext | None, - memories: MemoryListResponse | None, + user_memory: UserMemoryContent | None, run_input: RunAgentInput, ) -> StageExecutionResult: messages_for_router = self._build_router_messages( @@ -260,7 +261,7 @@ class AgentScopeRunner: now_utc=datetime.now(timezone.utc), runtime_client_time=runtime_client_time, tools=None, - memories=memories, + user_memory=user_memory, ), "system", ), @@ -319,7 +320,7 @@ class AgentScopeRunner: worker_output_model: type[WorkerAgentOutputLite], pipeline: PipelineLike, runtime_client_time: ClientTimeContext | None, - memories: MemoryListResponse | None, + work_memory: WorkProfileContent | None, ) -> StageExecutionResult: tracking_model = self._build_model(stage_config=stage_config) emitter = PipelineStageEmitter( @@ -340,7 +341,7 @@ class AgentScopeRunner: runtime_client_time=runtime_client_time, extra_context=stage_config.extra_context, tools=None, - memories=memories, + work_memory=work_memory, ), toolkit=toolkit, model=tracking_model, diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index 61a5cc8..7c52889 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -20,8 +20,8 @@ from core.config.settings import config from core.db.session import AsyncSessionLocal from core.logging import get_logger from core.taskiq.app import worker_agent_broker, worker_automation_broker -from schemas.automation import MemoryContextConfig, RuntimeConfig -from schemas.memories import MemoryListResponse +from schemas.automation import MessageContextConfig, RuntimeConfig +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent from schemas.messages.chat_message import ( AgentChatMessageMetadata, extract_user_message_attachments, @@ -30,7 +30,7 @@ from schemas.user import UserContext from services.base.redis import get_or_init_redis_client from services.base.supabase import supabase_service from v1.agent.repository import AgentRepository -from v1.memories.repository import MemoriesRepository +from v1.memories.repository import SQLAlchemyMemoriesRepository from v1.memories.service import MemoriesService from v1.users.dependencies import get_user_service @@ -83,7 +83,7 @@ async def _build_recent_context_messages( *, session: Any, thread_id: str, - context_config: "MemoryContextConfig", + context_config: "MessageContextConfig", ) -> list[Msg]: context_service = AgentContextService(repository=AgentRepository(session)) result = await context_service.load_context_messages( @@ -194,11 +194,16 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: orchestrator = _load_runtime() async with AsyncSessionLocal() as session: + current_user = CurrentUser(id=owner_id) user_context = await _build_user_context(owner_id=owner_id, session=session) - memories_service = MemoriesService(MemoriesRepository(session)) - memories: MemoryListResponse = await memories_service.get_all_memories( - owner_id=owner_id + memories_service = MemoriesService( + repository=SQLAlchemyMemoriesRepository(session), + session=session, + current_user=current_user, ) + memories_result = await memories_service.get_all_memories() + user_memory: UserMemoryContent | None = memories_result.get("user_memory") + work_memory: WorkProfileContent | None = memories_result.get("work_memory") redis_client = await get_or_init_redis_client() bus = RedisStreamBus( @@ -229,7 +234,8 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: context_messages=context_messages, user_context=user_context, runtime_config=runtime_config, - memories=memories, + user_memory=user_memory, + work_memory=work_memory, ) logger.info( "agentscope runtime task completed", diff --git a/backend/src/core/agentscope/services/context_service.py b/backend/src/core/agentscope/services/context_service.py index 2ebc840..4260462 100644 --- a/backend/src/core/agentscope/services/context_service.py +++ b/backend/src/core/agentscope/services/context_service.py @@ -6,7 +6,7 @@ from typing import Any, Protocol from schemas.agent.visibility import SystemVisibilityBit, bit_mask -from schemas.automation import ContextWindowMode, MemoryContextConfig +from schemas.automation import ContextWindowMode, MessageContextConfig _DEFAULT_CONTEXT_WINDOW_USER_MESSAGES = 20 @@ -86,7 +86,7 @@ class AgentContextService: self, *, thread_id: str, - context_config: MemoryContextConfig, + context_config: MessageContextConfig, ) -> dict[str, object] | None: visibility_mask = bit_mask(bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)) context_loader = CONTEXT_LOADER_REGISTRY.resolve( diff --git a/backend/src/core/agentscope/tools/custom/__init__.py b/backend/src/core/agentscope/tools/custom/__init__.py index 8aa2f73..ffb233e 100644 --- a/backend/src/core/agentscope/tools/custom/__init__.py +++ b/backend/src/core/agentscope/tools/custom/__init__.py @@ -6,10 +6,16 @@ from core.agentscope.tools.custom.calendar import ( from core.agentscope.tools.custom.user_lookup import ( user_lookup, ) +from core.agentscope.tools.custom.memory import ( + memory_forget, + memory_write, +) __all__ = [ "calendar_read", "calendar_write", "calendar_share", "user_lookup", + "memory_write", + "memory_forget", ] diff --git a/backend/src/core/agentscope/tools/custom/memory.py b/backend/src/core/agentscope/tools/custom/memory.py new file mode 100644 index 0000000..80c7bef --- /dev/null +++ b/backend/src/core/agentscope/tools/custom/memory.py @@ -0,0 +1,330 @@ +from copy import deepcopy +from typing import Annotated, Any, cast +from uuid import UUID + +from agentscope.tool import ToolResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator +from sqlalchemy.ext.asyncio import AsyncSession + +from core.agentscope.tools.tool_call_context import get_current_tool_call_id +from core.agentscope.tools.utils.memory_domain import ( + create_memories_service, + map_memory_exception, +) +from core.agentscope.tools.utils.tool_response_builder import ( + build_error_output, + build_tool_response, +) +from models.memories import MemoryType +from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent + + +class MemoryWriteArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + memory_type: MemoryType = MemoryType.USER + user_content: UserMemoryContent | None = None + work_content: WorkProfileContent | None = None + + @model_validator(mode="after") + def validate_content(self) -> "MemoryWriteArgs": + if self.memory_type == MemoryType.USER: + if self.user_content is None or self.work_content is not None: + raise ValueError("memory_type=user requires user_content only") + else: + if self.work_content is None or self.user_content is not None: + raise ValueError("memory_type=work requires work_content only") + return self + + +class MemoryForgetArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + memory_type: MemoryType = MemoryType.USER + forget_paths: list[str] = Field(min_length=1, max_length=100) + + @model_validator(mode="after") + def validate_forget_paths(self) -> "MemoryForgetArgs": + allowed_roots = ( + set(UserMemoryContent.model_fields) + if self.memory_type == MemoryType.USER + else set(WorkProfileContent.model_fields) + ) + normalized: list[str] = [] + for raw_path in self.forget_paths: + path = raw_path.strip() + if not path: + continue + parts = [part for part in path.split(".") if part] + if not parts: + continue + if len(parts) > 5: + raise ValueError("forget path depth exceeds limit") + if parts[0] not in allowed_roots: + raise ValueError("forget path root is not allowed") + normalized.append(path) + if not normalized: + raise ValueError("forget_paths cannot be empty") + self.forget_paths = normalized + return self + + +def _memory_error_output( + *, + tool_name: str, + tool_call_args: dict[str, Any], + code: str, + message: str, + retryable: bool, +) -> ToolResponse: + output = build_error_output( + tool_name=tool_name, + tool_call_id=get_current_tool_call_id(tool_name=tool_name), + code=code, + message=message, + retryable=retryable, + ) + output = output.model_copy(update={"tool_call_args": tool_call_args}) + return build_tool_response(output) + + +def _validate_runtime_context( + *, + tool_name: str, + tool_call_args: dict[str, Any], + session: Any, + owner_id: Any, +) -> ToolResponse | None: + if session is None or owner_id is None: + return _memory_error_output( + tool_name=tool_name, + tool_call_args=tool_call_args, + code="MISSING_RUNTIME_ARGS", + message="记忆工具缺少运行时参数", + retryable=False, + ) + return None + + +def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + merged = deepcopy(base) + for key, value in patch.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge_dict(cast(dict[str, Any], merged[key]), value) + else: + merged[key] = value + return merged + + +def _remove_content_paths( + base_payload: dict[str, Any], + paths: list[str], +) -> tuple[dict[str, Any], list[str]]: + result = deepcopy(base_payload) + removed: list[str] = [] + for raw_path in paths: + path = raw_path.strip() + if not path: + continue + keys = [part for part in path.split(".") if part] + if not keys: + continue + if _delete_nested_path(result, keys): + removed.append(path) + return result, removed + + +def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool: + current: dict[str, Any] = payload + for key in keys[:-1]: + next_value = current.get(key) + if not isinstance(next_value, dict): + return False + current = next_value + leaf = keys[-1] + if leaf in current: + del current[leaf] + return True + return False + + +async def memory_write( + memory_type: Annotated[ + str, + Field(description="Memory type: user or work."), + ] = "user", + user_content: Annotated[ + UserMemoryContent | None, + Field(description="Patch payload for user memory content."), + ] = None, + work_content: Annotated[ + WorkProfileContent | None, + Field(description="Patch payload for work memory content."), + ] = None, + session: Any = None, + owner_id: Any = None, +) -> ToolResponse: + tool_name = "memory_write" + tool_call_args: dict[str, Any] = { + "memory_type": memory_type, + "user_content": user_content, + "work_content": work_content, + } + runtime_error = _validate_runtime_context( + tool_name=tool_name, + tool_call_args=tool_call_args, + session=session, + owner_id=owner_id, + ) + if runtime_error is not None: + return runtime_error + + try: + parsed_args = MemoryWriteArgs.model_validate(tool_call_args) + service = create_memories_service( + session=cast(AsyncSession, session), + owner_id=cast(UUID, owner_id), + ) + existing = await service.get_memory_model(memory_type=parsed_args.memory_type) + + if parsed_args.memory_type == MemoryType.USER: + base_model = ( + UserMemoryContent.model_validate(existing.content) + if existing is not None + else UserMemoryContent() + ) + patch_model = cast(UserMemoryContent, parsed_args.user_content) + merged = _deep_merge_dict( + base_model.model_dump(), + patch_model.model_dump(exclude_unset=True), + ) + validated = UserMemoryContent.model_validate(merged) + await service.update_user_memory( + content=validated, + ) + else: + base_model = ( + WorkProfileContent.model_validate(existing.content) + if existing is not None + else WorkProfileContent() + ) + patch_model = cast(WorkProfileContent, parsed_args.work_content) + merged = _deep_merge_dict( + base_model.model_dump(), + patch_model.model_dump(exclude_unset=True), + ) + validated = WorkProfileContent.model_validate(merged) + await service.update_work_memory( + content=validated, + ) + + summary = f"status=success memory_type={parsed_args.memory_type.value}" + return build_tool_response( + ToolAgentOutput( + tool_name=tool_name, + tool_call_id=get_current_tool_call_id(tool_name=tool_name), + tool_call_args=tool_call_args, + status=ToolStatus.SUCCESS, + result=summary, + ) + ) + except Exception as exc: # noqa: BLE001 + code, message, retryable = map_memory_exception(exc) + return _memory_error_output( + tool_name=tool_name, + tool_call_args=tool_call_args, + code=code, + message=message, + retryable=retryable, + ) + + +async def memory_forget( + memory_type: Annotated[ + str, + Field(description="Memory type: user or work."), + ] = "user", + forget_paths: Annotated[ + list[str] | None, + Field(description="Dot paths to remove from content."), + ] = None, + session: Any = None, + owner_id: Any = None, +) -> ToolResponse: + tool_name = "memory_forget" + tool_call_args: dict[str, Any] = { + "memory_type": memory_type, + "forget_paths": forget_paths or [], + } + runtime_error = _validate_runtime_context( + tool_name=tool_name, + tool_call_args=tool_call_args, + session=session, + owner_id=owner_id, + ) + if runtime_error is not None: + return runtime_error + + try: + parsed_args = MemoryForgetArgs.model_validate(tool_call_args) + service = create_memories_service( + session=cast(AsyncSession, session), + owner_id=cast(UUID, owner_id), + ) + existing = await service.get_memory_model(memory_type=parsed_args.memory_type) + if existing is None: + summary = f"status=success memory_type={parsed_args.memory_type.value} forgotten=0" + return build_tool_response( + ToolAgentOutput( + tool_name=tool_name, + tool_call_id=get_current_tool_call_id(tool_name=tool_name), + tool_call_args=tool_call_args, + status=ToolStatus.SUCCESS, + result=summary, + ) + ) + + if parsed_args.memory_type == MemoryType.USER: + base_model = UserMemoryContent.model_validate(existing.content) + updated_dict, removed_paths = _remove_content_paths( + base_model.model_dump(), + parsed_args.forget_paths, + ) + validated = UserMemoryContent.model_validate(updated_dict) + await service.update_user_memory( + content=validated, + ) + else: + base_model = WorkProfileContent.model_validate(existing.content) + updated_dict, removed_paths = _remove_content_paths( + base_model.model_dump(), + parsed_args.forget_paths, + ) + validated = WorkProfileContent.model_validate(updated_dict) + await service.update_work_memory( + content=validated, + ) + + summary = ( + f"status=success memory_type={parsed_args.memory_type.value} forgotten={len(removed_paths)} " + f"skipped=0" + ) + return build_tool_response( + ToolAgentOutput( + tool_name=tool_name, + tool_call_id=get_current_tool_call_id(tool_name=tool_name), + tool_call_args=tool_call_args, + status=ToolStatus.SUCCESS, + result=summary, + ) + ) + except Exception as exc: # noqa: BLE001 + code, message, retryable = map_memory_exception(exc) + return _memory_error_output( + tool_name=tool_name, + tool_call_args=tool_call_args, + code=code, + message=message, + retryable=retryable, + ) diff --git a/backend/src/core/agentscope/tools/tool_config.py b/backend/src/core/agentscope/tools/tool_config.py index 2ecb525..c259a38 100644 --- a/backend/src/core/agentscope/tools/tool_config.py +++ b/backend/src/core/agentscope/tools/tool_config.py @@ -4,17 +4,13 @@ from dataclasses import dataclass from enum import Enum -class ToolGroup(str, Enum): - READ = "read" - EXECUTE = "execute" - MEMORY = "memory" - - class AgentTool(str, Enum): CALENDAR_READ = "calendar.read" CALENDAR_WRITE = "calendar.write" CALENDAR_SHARE = "calendar.share" USER_LOOKUP = "user.lookup" + MEMORY_WRITE = "memory.write" + MEMORY_FORGET = "memory.forget" @dataclass(frozen=True) @@ -25,29 +21,32 @@ class ToolApprovalConfig: @dataclass(frozen=True) class ToolConfig: name: str - group: ToolGroup approval: ToolApprovalConfig TOOL_CONFIGS: dict[str, ToolConfig] = { "calendar_read": ToolConfig( name="calendar_read", - group=ToolGroup.READ, approval=ToolApprovalConfig(required=False), ), "user_lookup": ToolConfig( name="user_lookup", - group=ToolGroup.MEMORY, approval=ToolApprovalConfig(required=False), ), "calendar_write": ToolConfig( name="calendar_write", - group=ToolGroup.EXECUTE, approval=ToolApprovalConfig(required=False), ), "calendar_share": ToolConfig( name="calendar_share", - group=ToolGroup.EXECUTE, + approval=ToolApprovalConfig(required=False), + ), + "memory_write": ToolConfig( + name="memory_write", + approval=ToolApprovalConfig(required=False), + ), + "memory_forget": ToolConfig( + name="memory_forget", approval=ToolApprovalConfig(required=False), ), } @@ -57,6 +56,8 @@ AGENT_TOOL_TO_FUNCTION_NAME: dict[AgentTool, str] = { AgentTool.CALENDAR_WRITE: "calendar_write", AgentTool.CALENDAR_SHARE: "calendar_share", AgentTool.USER_LOOKUP: "user_lookup", + AgentTool.MEMORY_WRITE: "memory_write", + AgentTool.MEMORY_FORGET: "memory_forget", } TOOL_NAME_ALIASES: dict[str, AgentTool] = { @@ -68,6 +69,10 @@ TOOL_NAME_ALIASES: dict[str, AgentTool] = { "calendar_share": AgentTool.CALENDAR_SHARE, AgentTool.USER_LOOKUP.value: AgentTool.USER_LOOKUP, "user_lookup": AgentTool.USER_LOOKUP, + AgentTool.MEMORY_WRITE.value: AgentTool.MEMORY_WRITE, + "memory_write": AgentTool.MEMORY_WRITE, + AgentTool.MEMORY_FORGET.value: AgentTool.MEMORY_FORGET, + "memory_forget": AgentTool.MEMORY_FORGET, } diff --git a/backend/src/core/agentscope/tools/toolkit.py b/backend/src/core/agentscope/tools/toolkit.py index 7eaa793..13262ac 100644 --- a/backend/src/core/agentscope/tools/toolkit.py +++ b/backend/src/core/agentscope/tools/toolkit.py @@ -10,6 +10,10 @@ from core.agentscope.tools.custom.calendar import ( calendar_share, calendar_write, ) +from core.agentscope.tools.custom.memory import ( + memory_forget, + memory_write, +) from core.agentscope.tools.custom.user_lookup import user_lookup from core.agentscope.tools.tool_config import ( TOOL_CONFIGS, @@ -23,6 +27,8 @@ TOOL_FUNCTIONS: dict[str, Any] = { "calendar_write": calendar_write, "calendar_share": calendar_share, "user_lookup": user_lookup, + "memory_write": memory_write, + "memory_forget": memory_forget, } diff --git a/backend/src/core/agentscope/tools/utils/memory_domain.py b/backend/src/core/agentscope/tools/utils/memory_domain.py new file mode 100644 index 0000000..293e0b0 --- /dev/null +++ b/backend/src/core/agentscope/tools/utils/memory_domain.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from v1.memories.repository import SQLAlchemyMemoriesRepository +from v1.memories.service import MemoriesService + + +def create_memories_service( + session: AsyncSession, + owner_id: UUID, +) -> MemoriesService: + return MemoriesService( + repository=SQLAlchemyMemoriesRepository(session), + session=session, + current_user=CurrentUser(id=owner_id), + ) + + +def map_memory_exception(exc: Exception) -> tuple[str, str, bool]: + if isinstance(exc, HTTPException): + detail = exc.detail + if isinstance(detail, str) and detail.strip(): + return "OPERATION_FAILED", detail.strip(), exc.status_code >= 500 + return "OPERATION_FAILED", "记忆操作失败", exc.status_code >= 500 + if isinstance(exc, ValueError): + return "INVALID_ARGUMENT", "请求参数无效", False + return "INTERNAL_ERROR", "记忆操作失败", True diff --git a/backend/src/core/automation/__init__.py b/backend/src/core/automation/__init__.py index bec911b..7a8a454 100644 --- a/backend/src/core/automation/__init__.py +++ b/backend/src/core/automation/__init__.py @@ -1,11 +1,3 @@ -from core.automation.scheduler import ( - AutomationSchedulerService, - DispatchResult, - SqlAlchemyAutomationSchedulerRepository, -) +from core.automation.scheduler import run_automation_scheduler_scan -__all__ = [ - "AutomationSchedulerService", - "DispatchResult", - "SqlAlchemyAutomationSchedulerRepository", -] +__all__ = ["run_automation_scheduler_scan"] diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml new file mode 100644 index 0000000..1a11dca --- /dev/null +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -0,0 +1,8 @@ +input_template: 请基于最近两天用户聊天上下文提取用户记忆;如果已有记忆内容变化请更新;如果记忆已失效请执行遗忘。 +enabled_tools: + - memory.write + - memory.forget +context: + source: latest_chat + window_mode: day + window_count: 2 diff --git a/backend/src/models/automation_jobs.py b/backend/src/models/automation_jobs.py index ba974a5..86f0a1d 100644 --- a/backend/src/models/automation_jobs.py +++ b/backend/src/models/automation_jobs.py @@ -32,6 +32,10 @@ class AutomationJob(TimestampMixin, SoftDeleteMixin, Base): UUID(as_uuid=True), nullable=False, ) + bootstrap_key: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + ) title: Mapped[str] = mapped_column( String(255), nullable=False, diff --git a/backend/src/models/memories.py b/backend/src/models/memories.py index ded0432..2aeae8a 100644 --- a/backend/src/models/memories.py +++ b/backend/src/models/memories.py @@ -16,12 +16,6 @@ class MemoryType(str, Enum): WORK = "work" -class MemorySource(str, Enum): - MANUAL = "manual" - AGENT = "agent" - IMPORTED = "imported" - - class MemoryStatus(str, Enum): ACTIVE = "active" DISABLED = "disabled" @@ -46,15 +40,10 @@ class Memory(TimestampMixin, Base): String(20), nullable=False, ) - title: Mapped[str | None] = mapped_column(String(255), nullable=True) content: Mapped[dict] = mapped_column( json_jsonb, nullable=False, ) - source: Mapped[MemorySource] = mapped_column( - String(20), - nullable=False, - ) status: Mapped[MemoryStatus] = mapped_column( String(20), nullable=False, diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py index ec28814..cc79b61 100644 --- a/backend/src/schemas/__init__.py +++ b/backend/src/schemas/__init__.py @@ -12,7 +12,6 @@ from schemas.inbox.messages import ( parse_calendar_content, ) from schemas.invite_codes import InviteCodeRewardConfig -from schemas.memories import MemoryContext from schemas.messages import AgentChatMessageMetadata from schemas.schedule.items import ( AttachmentType, @@ -36,7 +35,6 @@ __all__ = [ "InboxMessageStatus", "InboxMessageType", "InviteCodeRewardConfig", - "MemoryContext", "ScheduleItemMetadata", "ScheduleItemMetadataAttachment", "ScheduleItemSourceType", diff --git a/backend/src/schemas/automation/__init__.py b/backend/src/schemas/automation/__init__.py index f51f78b..a520783 100644 --- a/backend/src/schemas/automation/__init__.py +++ b/backend/src/schemas/automation/__init__.py @@ -20,7 +20,7 @@ class ContextWindowMode(str, Enum): NUMBER = "number" -class MemoryContextConfig(BaseModel): +class MessageContextConfig(BaseModel): model_config = ConfigDict(extra="forbid") source: ContextSource = ContextSource.LATEST_CHAT @@ -32,7 +32,7 @@ class RuntimeConfig(BaseModel): model_config = ConfigDict(extra="forbid") enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32) - context: MemoryContextConfig = Field(default_factory=MemoryContextConfig) + context: MessageContextConfig = Field(default_factory=MessageContextConfig) class AutomationJobConfig(RuntimeConfig): @@ -46,6 +46,7 @@ class AutomationJob(BaseModel): id: UUID owner_id: UUID + bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64) title: str = Field(..., min_length=1, max_length=255) config: AutomationJobConfig schedule_type: ScheduleType @@ -63,6 +64,7 @@ class AutomationJob(BaseModel): return cls( id=obj.id, owner_id=obj.owner_id, + bootstrap_key=obj.bootstrap_key, title=obj.title, config=AutomationJobConfig.model_validate(obj.config or {}), schedule_type=obj.schedule_type, diff --git a/backend/src/schemas/memories/__init__.py b/backend/src/schemas/memories/__init__.py index d8027c7..05ee45a 100644 --- a/backend/src/schemas/memories/__init__.py +++ b/backend/src/schemas/memories/__init__.py @@ -2,10 +2,19 @@ from __future__ import annotations from datetime import datetime from enum import Enum -from typing import Any, ClassVar, Literal +from typing import ClassVar, Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict + +from schemas.memories.memory_content import ( + TeamMember, + UserMemoryContent, + UserPreferences, + WorkHabit, + WorkProfileContent, + WorkProject, +) class MemoryType(str, Enum): @@ -13,12 +22,6 @@ class MemoryType(str, Enum): WORK = "work" -class MemorySource(str, Enum): - MANUAL = "manual" - AGENT = "agent" - IMPORTED = "imported" - - class MemoryStatus(str, Enum): ACTIVE = "active" DISABLED = "disabled" @@ -33,38 +36,20 @@ class MemoryModel(BaseModel): owner_id: UUID agent_id: UUID | None = None memory_type: Literal["user", "work"] - title: str | None = None - content: dict[str, Any] - source: MemorySource + content: UserMemoryContent | WorkProfileContent status: MemoryStatus created_at: datetime updated_at: datetime -class MemoryContext(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - memory_type: MemoryType - source: MemorySource - title: str | None = None - content: dict[str, Any] - created_at: datetime - updated_at: datetime - - -class MemoryListResponse(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - owner_id: UUID - memories: list[MemoryContext] = Field(default_factory=list) - total: int - - __all__ = [ - "MemoryContext", - "MemoryListResponse", "MemoryModel", - "MemorySource", "MemoryStatus", "MemoryType", + "TeamMember", + "UserMemoryContent", + "UserPreferences", + "WorkHabit", + "WorkProfileContent", + "WorkProject", ] diff --git a/backend/src/schemas/memories/memory_content.py b/backend/src/schemas/memories/memory_content.py new file mode 100644 index 0000000..cae6b67 --- /dev/null +++ b/backend/src/schemas/memories/memory_content.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +Weekday = Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +ProjectStatus = Literal["planned", "active", "paused", "completed"] +PreferenceLevel = Literal["like", "neutral", "avoid"] +MemorySource = Literal["user", "inferred", "calendar", "email", "agent"] + + +class MemoryMeta(BaseModel): + source: MemorySource | None = Field(default=None, description="记忆来源") + confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="置信度") + last_updated_at: datetime | None = Field(default=None, description="最后更新时间") + + +class TimeWindow(BaseModel): + weekdays: list[Weekday] = Field(default_factory=list, description="适用星期") + start: str = Field(description="开始时间,HH:MM") + end: str = Field(description="结束时间,HH:MM") + + +class PersonMemory(BaseModel): + name: str = Field(description="人物姓名") + relationship: str | None = Field( + default=None, description="与用户关系,如家人/同事/导师/朋友" + ) + role: str | None = Field(default=None, description="角色,如老板/导师/合作方") + preferred_contact_channel: str | None = Field( + default=None, description="偏好联系方式" + ) + notes: str | None = Field(default=None, description="补充说明") + meta: MemoryMeta = Field(default_factory=MemoryMeta) + + +class PlaceMemory(BaseModel): + name: str = Field(description="地点名称") + category: str | None = Field( + default=None, description="地点类别,如home/office/gym/cafe" + ) + address: str | None = Field(default=None, description="地址") + timezone: str | None = Field(default=None, description="地点时区") + commute_minutes: int | None = Field(default=None, ge=0, description="典型通勤时长") + preference: PreferenceLevel | None = Field(default=None, description="地点偏好") + notes: str | None = Field(default=None, description="补充说明") + meta: MemoryMeta = Field(default_factory=MemoryMeta) + + +class UserPreferences(BaseModel): + communication_style: str | None = Field( + default=None, description="沟通风格,如简洁直接" + ) + language_preference: list[str] = Field(default_factory=list, description="语言偏好") + location_preference: str | None = Field( + default=None, description="地点偏好,如喜欢远程" + ) + work_lifestyle: str | None = Field(default=None, description="作息方式,如早睡早起") + notification_preference: list[str] = Field( + default_factory=list, description="通知方式偏好" + ) + + +class SchedulingPreferences(BaseModel): + productive_windows: list[TimeWindow] = Field( + default_factory=list, description="高效率时段" + ) + preferred_meeting_windows: list[TimeWindow] = Field( + default_factory=list, description="偏好的会议时段" + ) + no_meeting_windows: list[TimeWindow] = Field( + default_factory=list, description="尽量不安排会议的时段" + ) + deep_work_windows: list[TimeWindow] = Field( + default_factory=list, description="深度工作时段" + ) + preferred_meeting_duration_minutes: list[int] = Field( + default_factory=lambda: [30, 60], description="偏好的会议时长" + ) + meeting_buffer_minutes: int | None = Field( + default=None, ge=0, description="会议间缓冲时间" + ) + max_meetings_per_day: int | None = Field( + default=None, ge=0, description="单日会议上限" + ) + notes: str | None = Field(default=None, description="其他排程说明") + + +class RecurringRoutine(BaseModel): + name: str = Field(description="周期性安排名称") + description: str | None = Field(default=None, description="周期性安排描述") + cadence: str | None = Field( + default=None, description="频率,如daily/weekly/monthly" + ) + time_windows: list[TimeWindow] = Field( + default_factory=list, description="通常发生时段" + ) + importance: str | None = Field(default=None, description="重要程度") + meta: MemoryMeta = Field(default_factory=MemoryMeta) + + +class UserMemoryContent(BaseModel): + model_config = ConfigDict(extra="allow") + + occupation: str | None = Field(default=None, description="职业") + timezone: str | None = Field(default=None, description="时区") + primary_language: str | None = Field(default=None, description="主要语言") + people: list[PersonMemory] = Field(default_factory=list, description="重要人物") + places: list[PlaceMemory] = Field(default_factory=list, description="常去地点") + preferences: UserPreferences = Field(default_factory=UserPreferences) + scheduling_preferences: SchedulingPreferences = Field( + default_factory=SchedulingPreferences + ) + interests: list[str] = Field(default_factory=list, description="兴趣爱好") + avoid_topics: list[str] = Field(default_factory=list, description="不想讨论的话题") + custom_rules: list[str] = Field(default_factory=list, description="用户自定义规则") + recurring_routines: list[RecurringRoutine] = Field( + default_factory=list, description="周期性习惯/安排" + ) + + +class Milestone(BaseModel): + name: str = Field(description="里程碑名称") + due_date: date | None = Field(default=None, description="截止日期") + status: str | None = Field(default=None, description="状态") + notes: str | None = Field(default=None, description="补充说明") + + +class WorkProject(BaseModel): + name: str = Field(description="项目名") + description: str | None = Field(default=None, description="项目描述") + status: ProjectStatus | None = Field(default=None, description="项目状态") + priority: str | None = Field(default=None, description="项目优先级") + deadline: date | None = Field(default=None, description="项目截止时间") + collaborators: list[str] = Field(default_factory=list, description="协作人") + key_milestones: list[Milestone] = Field( + default_factory=list, description="关键里程碑" + ) + notes: str | None = Field(default=None, description="补充说明") + meta: MemoryMeta = Field(default_factory=MemoryMeta) + + +class WorkHabit(BaseModel): + available_hours: list[TimeWindow] = Field( + default_factory=list, description="常规可工作时间" + ) + deep_work_blocks: list[TimeWindow] = Field( + default_factory=list, description="偏好的深度工作时间" + ) + preferred_meeting_windows: list[TimeWindow] = Field( + default_factory=list, description="偏好的会议时间" + ) + no_meeting_windows: list[TimeWindow] = Field( + default_factory=list, description="不希望开会的时间" + ) + preferred_meeting_duration_minutes: list[int] = Field( + default_factory=lambda: [30, 60], description="偏好的会议时长" + ) + notification_channel: str | None = Field(default=None, description="首选沟通渠道") + notes: str | None = Field(default=None, description="补充说明") + + +class TeamMember(BaseModel): + name: str = Field(description="成员姓名") + role: str | None = Field(default=None, description="团队角色") + relationship: str | None = Field( + default=None, description="关系,如直属上级/同事/合作方" + ) + preferred_contact_channel: str | None = Field( + default=None, description="偏好沟通渠道" + ) + notes: str | None = Field(default=None, description="补充说明") + meta: MemoryMeta = Field(default_factory=MemoryMeta) + + +class WorkProfileContent(BaseModel): + model_config = ConfigDict(extra="allow") + + occupation: str | None = Field(default=None, description="职业身份") + expertise: list[str] = Field(default_factory=list, description="专业领域") + preferred_tools: list[str] = Field(default_factory=list, description="惯用工具") + current_projects: list[WorkProject] = Field( + default_factory=list, description="长期项目画像" + ) + work_habits: WorkHabit = Field(default_factory=WorkHabit) + team_members: list[TeamMember] = Field(default_factory=list, description="团队成员") + team_context: str | None = Field(default=None, description="团队背景") + work_rules: list[str] = Field( + default_factory=list, description="工作规则或默认原则" + ) diff --git a/backend/src/v1/agent/system_agents_config.py b/backend/src/v1/agent/system_agents_config.py index e32cbb8..550de07 100644 --- a/backend/src/v1/agent/system_agents_config.py +++ b/backend/src/v1/agent/system_agents_config.py @@ -9,11 +9,12 @@ from pathlib import Path import yaml from pydantic import ValidationError +from core.agentscope.tools.tool_config import AgentTool from schemas.agent.system_agent import SystemAgentLLMConfig from schemas.automation import ( ContextSource, ContextWindowMode, - MemoryContextConfig, + MessageContextConfig, RuntimeConfig, ) @@ -38,9 +39,9 @@ def _load_system_agents_yaml(path: Path | None = None) -> dict: return loaded -def _parse_context_messages_config(yaml_config: dict | None) -> MemoryContextConfig: +def _parse_context_messages_config(yaml_config: dict | None) -> MessageContextConfig: if not yaml_config: - return MemoryContextConfig() + return MessageContextConfig() mode_str = yaml_config.get("mode", "day") count = yaml_config.get("count", 2) try: @@ -51,7 +52,7 @@ def _parse_context_messages_config(yaml_config: dict | None) -> MemoryContextCon window_mode = ContextWindowMode(mode_str) except ValueError: window_mode = ContextWindowMode.DAY - return MemoryContextConfig( + return MessageContextConfig( source=source, window_mode=window_mode, window_count=count, @@ -93,9 +94,9 @@ def build_runtime_config_from_system_agents( router_config.context_messages.model_dump() if router_config else None ) - enabled_tools: list[str] = [] + enabled_tools: list[AgentTool] = [] if worker_config and worker_config.enabled_tools: - enabled_tools = [str(t) for t in worker_config.enabled_tools] + enabled_tools = list(worker_config.enabled_tools) return RuntimeConfig( enabled_tools=enabled_tools, diff --git a/backend/src/v1/auth/automation_static_config.py b/backend/src/v1/auth/automation_static_config.py new file mode 100644 index 0000000..5d81cd9 --- /dev/null +++ b/backend/src/v1/auth/automation_static_config.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +import re +from typing import Any + +import yaml + +from core.agentscope.tools.tool_config import AgentTool +from schemas.automation import AutomationJobConfig, MessageContextConfig + +_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") + + +def _automation_yaml_path(config_name: str) -> Path: + if not _CONFIG_NAME_PATTERN.fullmatch(config_name): + raise ValueError("invalid automation config name") + return ( + Path(__file__).resolve().parents[2] + / "core" + / "config" + / "static" + / "automation" + / f"{config_name}.yaml" + ) + + +@lru_cache(maxsize=16) +def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfig: + path = _automation_yaml_path(config_name) + with path.open("r", encoding="utf-8") as file: + loaded: Any = yaml.safe_load(file) or {} + if not isinstance(loaded, dict): + raise ValueError(f"invalid automation config format: {path}") + config = AutomationJobConfig.model_validate(loaded) + if config_name == "memory_extraction": + if config.enabled_tools != [AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET]: + raise ValueError( + "memory_extraction enabled_tools must be [memory.write, memory.forget]" + ) + if config.context != MessageContextConfig(window_count=2): + raise ValueError( + "memory_extraction context must be latest_chat/day with window_count=2" + ) + return config diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py index 7de951e..1b96624 100644 --- a/backend/src/v1/auth/dependencies.py +++ b/backend/src/v1/auth/dependencies.py @@ -1,8 +1,27 @@ from __future__ import annotations +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db from v1.auth.gateway import SupabaseAuthGateway +from v1.auth.registration_bootstrap import ( + RegistrationAutomationBootstrapService, + RegistrationBootstrapRepository, +) from v1.auth.service import AuthService -def get_auth_service() -> AuthService: - return AuthService(gateway=SupabaseAuthGateway()) +def get_auth_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> AuthService: + bootstrapper = RegistrationAutomationBootstrapService( + repository=RegistrationBootstrapRepository(session=session), + session=session, + ) + return AuthService( + gateway=SupabaseAuthGateway(), + registration_bootstrapper=bootstrapper, + ) diff --git a/backend/src/v1/auth/registration_bootstrap.py b/backend/src/v1/auth/registration_bootstrap.py new file mode 100644 index 0000000..10b9155 --- /dev/null +++ b/backend/src/v1/auth/registration_bootstrap.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Protocol +from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from core.logging import get_logger +from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType +from models.memories import MemoryType +from models.profile import Profile +from schemas.automation import AutomationJobConfig +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent +from schemas.user.context import parse_profile_settings +from v1.auth.automation_static_config import load_static_automation_job_config +from v1.auth.schemas import RegistrationBootstrapRequest +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: + self._session = session + self._memories_repository = SQLAlchemyMemoriesRepository(session) + + async def get_profile_timezone(self, *, user_id: UUID) -> str: + stmt = select(Profile.settings).where(Profile.id == user_id) + settings = (await self._session.execute(stmt)).scalar_one_or_none() + parsed = parse_profile_settings( + settings if isinstance(settings, dict) else None + ) + return parsed.preferences.timezone + + async def insert_bootstrap_automation_job_if_absent( + self, + *, + owner_id: UUID, + bootstrap_key: str, + title: str, + config: AutomationJobConfig, + timezone_name: str, + run_at: datetime, + next_run_at: datetime, + ) -> bool: + stmt = ( + insert(AutomationJob) + .values( + id=uuid4(), + owner_id=owner_id, + bootstrap_key=bootstrap_key, + title=title, + config=config.model_dump(mode="json"), + schedule_type=ScheduleType.DAILY, + run_at=run_at, + next_run_at=next_run_at, + timezone=timezone_name, + status=AutomationJobStatus.ACTIVE, + created_by=owner_id, + ) + .on_conflict_do_nothing( + index_elements=["owner_id", "bootstrap_key"], + index_where=AutomationJob.deleted_at.is_(None) + & AutomationJob.bootstrap_key.is_not(None), + ) + .returning(AutomationJob.id) + ) + inserted_id = (await self._session.execute(stmt)).scalar_one_or_none() + await self._session.flush() + return inserted_id is not None + + async def upsert_initial_memory( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: + return await self._memories_repository.create_if_absent( + owner_id=owner_id, + memory_type=memory_type, + content=content, + ) + + +class RegistrationBootstrapper(Protocol): + async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: ... + + +class RegistrationBootstrapRepositoryLike(Protocol): + async def get_profile_timezone(self, *, user_id: UUID) -> str: ... + + async def insert_bootstrap_automation_job_if_absent( + self, + *, + owner_id: UUID, + bootstrap_key: str, + title: str, + config: AutomationJobConfig, + timezone_name: str, + run_at: datetime, + next_run_at: datetime, + ) -> bool: ... + + async def upsert_initial_memory( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: ... + + +class SessionLike(Protocol): + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... + + +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(UTC), next_local.astimezone(UTC) + + +class RegistrationAutomationBootstrapService: + def __init__( + self, + *, + repository: RegistrationBootstrapRepositoryLike, + session: SessionLike, + ) -> None: + self._repository = repository + self._session = session + + async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: + request = RegistrationBootstrapRequest.model_validate({"user_id": user_id}) + owner_id = request.user_id + timezone_name = await self._repository.get_profile_timezone(user_id=owner_id) + + definitions = [ + { + "bootstrap_key": "memory_extraction", + "config_name": "memory_extraction", + "title": "Memory Agent", + "local_hour": _LOCAL_RUN_HOUR, + "local_minute": _LOCAL_RUN_MINUTE, + } + ] + + try: + inserted_any = False + created_or_updated_memory = False + + user_initialized = await self._repository.upsert_initial_memory( + owner_id=owner_id, + memory_type=MemoryType.USER, + content=UserMemoryContent().model_dump(mode="json"), + ) + work_initialized = await self._repository.upsert_initial_memory( + owner_id=owner_id, + memory_type=MemoryType.WORK, + content=WorkProfileContent().model_dump(mode="json"), + ) + created_or_updated_memory = user_initialized or work_initialized + + for definition in definitions: + bootstrap_key = str(definition["bootstrap_key"]) + job_config = load_static_automation_job_config( + config_name=str(definition["config_name"]) + ) + 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"]), + ) + inserted = ( + await self._repository.insert_bootstrap_automation_job_if_absent( + owner_id=owner_id, + bootstrap_key=bootstrap_key, + title=str(definition["title"]), + config=job_config, + timezone_name=timezone_name, + run_at=run_at, + next_run_at=next_run_at, + ) + ) + inserted_any = inserted_any or inserted + if inserted_any or created_or_updated_memory: + await self._session.commit() + logger.info( + "user automation jobs bootstrapped", + user_id=user_id, + timezone=timezone_name, + memory_initialized=created_or_updated_memory, + ) + except Exception: + await self._session.rollback() + raise diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 8542180..4fc3f32 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -1,5 +1,7 @@ from __future__ import annotations +from uuid import UUID + from pydantic import BaseModel, ConfigDict, Field SUPABASE_PASSWORD_MIN_LENGTH = 6 @@ -49,3 +51,9 @@ class UserByPhoneResponse(BaseModel): class OtpSendResponse(BaseModel): phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class RegistrationBootstrapRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_id: UUID diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py index 8fb0803..d16e2ce 100644 --- a/backend/src/v1/auth/service.py +++ b/backend/src/v1/auth/service.py @@ -28,9 +28,15 @@ class AuthServiceGateway(Protocol): class AuthService: _gateway: AuthServiceGateway + _registration_bootstrapper: RegistrationBootstrapper | None - def __init__(self, gateway: AuthServiceGateway) -> None: + def __init__( + self, + gateway: AuthServiceGateway, + registration_bootstrapper: "RegistrationBootstrapper | None" = None, + ) -> None: self._gateway = gateway + self._registration_bootstrapper = registration_bootstrapper async def send_otp(self, request: OtpSendRequest) -> None: await self._gateway.send_otp(request) @@ -38,10 +44,20 @@ class AuthService: async def create_phone_session( self, request: PhoneSessionCreateRequest ) -> SessionResponse: - return await self._gateway.create_phone_session(request) + response = await self._gateway.create_phone_session(request) + if self._registration_bootstrapper is not None: + await self._registration_bootstrapper.ensure_user_automation_jobs( + user_id=response.user.id + ) + return response async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: return await self._gateway.refresh_session(request) async def delete_session(self, refresh_token: str | None) -> None: await self._gateway.delete_session(refresh_token) + + +class RegistrationBootstrapper(Protocol): + async def ensure_user_automation_jobs(self, *, user_id: str) -> None: + raise NotImplementedError diff --git a/backend/src/v1/memories/dependencies.py b/backend/src/v1/memories/dependencies.py new file mode 100644 index 0000000..a35980f --- /dev/null +++ b/backend/src/v1/memories/dependencies.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db import get_db +from v1.memories.repository import SQLAlchemyMemoriesRepository +from v1.memories.service import MemoriesService +from v1.users.dependencies import get_current_user + + +async def get_memories_repository( + session: Annotated[AsyncSession, Depends(get_db)], +) -> SQLAlchemyMemoriesRepository: + return SQLAlchemyMemoriesRepository(session) + + +async def get_memories_service( + session: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> MemoriesService: + repository = SQLAlchemyMemoriesRepository(session) + return MemoriesService( + repository=repository, + session=session, + current_user=current_user, + ) diff --git a/backend/src/v1/memories/repository.py b/backend/src/v1/memories/repository.py index f9abf9a..c07362f 100644 --- a/backend/src/v1/memories/repository.py +++ b/backend/src/v1/memories/repository.py @@ -3,29 +3,162 @@ from __future__ import annotations from typing import TYPE_CHECKING, Protocol from uuid import UUID +from sqlalchemy.dialects.postgresql import insert from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository -from models.memories import Memory +from core.logging import get_logger +from models.memories import Memory, MemoryStatus, MemoryType if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession +logger = get_logger("v1.memories.repository") + class MemoriesRepositoryLike(Protocol): - async def get_active_memories(self, *, owner_id: UUID) -> list[Memory]: ... + async def create( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> Memory: ... + + async def get_by_type_for_owner( + self, *, owner_id: UUID, memory_type: MemoryType + ) -> Memory | None: ... + + async def get_user_memory_for_owner(self, *, owner_id: UUID) -> Memory | None: ... + + async def get_work_memory_for_owner(self, *, owner_id: UUID) -> Memory | None: ... + + async def create_if_absent( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: ... + + async def update_content( + self, + memory: Memory, + content: dict | None = None, + ) -> Memory: ... -class MemoriesRepository(BaseRepository[Memory]): +class SQLAlchemyMemoriesRepository(BaseRepository[Memory]): + _session: AsyncSession + def __init__(self, session: AsyncSession) -> None: super().__init__(session=session, model=Memory) + self._session = session - async def get_active_memories(self, *, owner_id: UUID) -> list[Memory]: - stmt = ( - select(Memory) - .where(Memory.owner_id == owner_id) - .where(Memory.status == "active") - .order_by(Memory.created_at.desc()) + async def create( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> Memory: + try: + memory = Memory( + owner_id=owner_id, + memory_type=memory_type, + content=content, + status=MemoryStatus.ACTIVE, + ) + self._session.add(memory) + await self._session.flush() + return memory + except SQLAlchemyError: + logger.exception( + "Failed to create memory", + owner_id=str(owner_id), + memory_type=memory_type.value, + ) + raise + + async def get_by_type_for_owner( + self, *, owner_id: UUID, memory_type: MemoryType + ) -> Memory | None: + try: + stmt = ( + select(Memory) + .where(Memory.owner_id == owner_id) + .where(Memory.memory_type == memory_type) + .where(Memory.status == MemoryStatus.ACTIVE) + ) + result = await self._session.execute(stmt) + if hasattr(result, "scalar_one_or_none"): + return result.scalar_one_or_none() + scalars = result.scalars() + rows = list(scalars.all()) + return rows[0] if rows else None + except SQLAlchemyError: + logger.exception( + "Failed to get memory by type for owner", + owner_id=str(owner_id), + memory_type=memory_type.value, + ) + raise + + async def get_user_memory_for_owner(self, *, owner_id: UUID) -> Memory | None: + return await self.get_by_type_for_owner( + owner_id=owner_id, memory_type=MemoryType.USER ) - result = await self._session.execute(stmt) - return list(result.scalars().all()) + + async def get_work_memory_for_owner(self, *, owner_id: UUID) -> Memory | None: + return await self.get_by_type_for_owner( + owner_id=owner_id, memory_type=MemoryType.WORK + ) + + async def create_if_absent( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: + try: + stmt = ( + insert(Memory) + .values( + owner_id=owner_id, + memory_type=memory_type, + content=content, + status=MemoryStatus.ACTIVE, + ) + .on_conflict_do_nothing(index_elements=["owner_id", "memory_type"]) + .returning(Memory.id) + ) + inserted_id = (await self._session.execute(stmt)).scalar_one_or_none() + await self._session.flush() + return inserted_id is not None + except SQLAlchemyError: + logger.exception( + "Failed to create memory if absent", + owner_id=str(owner_id), + memory_type=memory_type.value, + ) + raise + + async def update_content( + self, + memory: Memory, + content: dict | None = None, + ) -> Memory: + try: + if content is not None: + memory.content = content + + await self._session.flush() + return memory + except SQLAlchemyError: + logger.exception( + "Failed to update memory content", + memory_id=str(memory.id), + ) + raise diff --git a/backend/src/v1/memories/router.py b/backend/src/v1/memories/router.py new file mode 100644 index 0000000..b8fd8d2 --- /dev/null +++ b/backend/src/v1/memories/router.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent +from v1.memories.dependencies import get_memories_service +from v1.memories.schemas import ( + MemoryListResponse, + UserMemoryPartialUpdate, + UserMemoryUpdate, + WorkMemoryPartialUpdate, + WorkMemoryUpdate, +) +from v1.memories.service import MemoriesService + +router = APIRouter(prefix="/memories", tags=["memories"]) + + +@router.get("", response_model=MemoryListResponse) +async def get_all_memories( + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> MemoryListResponse: + result = await service.get_all_memories() + return MemoryListResponse( + user_memory=result["user_memory"], + work_memory=result["work_memory"], + ) + + +@router.get("/user", response_model=UserMemoryContent | None) +async def get_user_memory( + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> UserMemoryContent | None: + return await service.get_user_memory() + + +@router.get("/work", response_model=WorkProfileContent | None) +async def get_work_memory( + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> WorkProfileContent | None: + return await service.get_work_memory() + + +@router.put("/user", response_model=UserMemoryContent, status_code=status.HTTP_200_OK) +async def update_user_memory( + payload: UserMemoryUpdate, + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> UserMemoryContent: + return await service.update_user_memory( + content=payload.content, + ) + + +@router.put("/work", response_model=WorkProfileContent, status_code=status.HTTP_200_OK) +async def update_work_memory( + payload: WorkMemoryUpdate, + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> WorkProfileContent: + return await service.update_work_memory( + content=payload.content, + ) + + +@router.patch("/user", response_model=UserMemoryContent) +async def patch_user_memory( + payload: UserMemoryPartialUpdate, + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> UserMemoryContent: + return await service.patch_user_memory( + content=payload.content, + ) + + +@router.patch("/work", response_model=WorkProfileContent) +async def patch_work_memory( + payload: WorkMemoryPartialUpdate, + service: Annotated[MemoriesService, Depends(get_memories_service)], +) -> WorkProfileContent: + return await service.patch_work_memory( + content=payload.content, + ) diff --git a/backend/src/v1/memories/schemas.py b/backend/src/v1/memories/schemas.py new file mode 100644 index 0000000..b621bda --- /dev/null +++ b/backend/src/v1/memories/schemas.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent + + +class UserMemoryUpdate(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + content: UserMemoryContent + + +class WorkMemoryUpdate(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + content: WorkProfileContent + + +class UserMemoryPartialUpdate(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + content: UserMemoryContent | None = None + + +class WorkMemoryPartialUpdate(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + content: WorkProfileContent | None = None + + +class MemoryListResponse(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + user_memory: UserMemoryContent | None = None + work_memory: WorkProfileContent | None = None diff --git a/backend/src/v1/memories/service.py b/backend/src/v1/memories/service.py index d70829f..cb8e32c 100644 --- a/backend/src/v1/memories/service.py +++ b/backend/src/v1/memories/service.py @@ -1,53 +1,286 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING -from models.memories import Memory -from schemas.memories import MemoryContext, MemoryListResponse, MemorySource, MemoryType +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from models.memories import Memory, MemoryType +from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent from v1.memories.repository import MemoriesRepositoryLike +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.memories.service") + + +class MemoriesService(BaseService): + """Memories service handling user/work memory operations. + + Each user has exactly 2 memory records: + - user_type: stores personal preferences, people, places, etc. + - work_type: stores work profile, projects, team, etc. + + Responsibilities: + - Authorization checks + - Validation (ownership, memory type) + - Transaction boundary (commit/rollback) + - Converting ORM models to response schemas + """ -class MemoriesService: _repository: MemoriesRepositoryLike + _session: AsyncSession - def __init__(self, repository: MemoriesRepositoryLike) -> None: + def __init__( + self, + repository: MemoriesRepositoryLike, + session: AsyncSession, + current_user: CurrentUser | None, + ) -> None: + super().__init__(current_user=current_user) self._repository = repository + self._session = session - def _to_context(self, memory: Memory) -> MemoryContext: - return MemoryContext( - memory_type=MemoryType(memory.memory_type.value), - source=MemorySource(memory.source.value), - title=memory.title, - content=memory.content, - created_at=memory.created_at, - updated_at=memory.updated_at, + async def get_user_memory(self) -> UserMemoryContent | None: + user_id = self.require_user_id() + + try: + memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + if memory is None: + return None + + return self._parse_user_content(memory) + + async def get_work_memory(self) -> WorkProfileContent | None: + user_id = self.require_user_id() + + try: + memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + if memory is None: + return None + + return self._parse_work_content(memory) + + async def get_all_memories(self) -> dict: + user_id = self.require_user_id() + + try: + user_memory = await self._repository.get_user_memory_for_owner( + owner_id=user_id + ) + work_memory = await self._repository.get_work_memory_for_owner( + owner_id=user_id + ) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + return { + "user_memory": self._parse_user_content(user_memory) + if user_memory + else None, + "work_memory": self._parse_work_content(work_memory) + if work_memory + else None, + } + + async def update_user_memory( + self, + *, + content: UserMemoryContent, + ) -> UserMemoryContent: + user_id = self.require_user_id() + + try: + memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + if memory is None: + try: + memory = await self._repository.create( + owner_id=user_id, + memory_type=MemoryType.USER, + content=content.model_dump(), + ) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Memories service unavailable" + ) + else: + try: + memory = await self._repository.update_content( + memory=memory, + content=content.model_dump(), + ) + await self._session.commit() + await self._session.refresh(memory) + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Memories service unavailable" + ) + + logger.info( + "user_memory_updated", + extra={"user_id": str(user_id)}, ) - async def get_user_memories(self, *, owner_id: UUID) -> MemoryListResponse: - memories = await self._repository.get_active_memories(owner_id=owner_id) - user_memories = [ - self._to_context(memory) - for memory in memories - if memory.memory_type.value == "user" - ] - return MemoryListResponse( - owner_id=owner_id, memories=user_memories, total=len(user_memories) + return self._parse_user_content(memory) + + async def update_work_memory( + self, + *, + content: WorkProfileContent, + ) -> WorkProfileContent: + user_id = self.require_user_id() + + try: + memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + if memory is None: + try: + memory = await self._repository.create( + owner_id=user_id, + memory_type=MemoryType.WORK, + content=content.model_dump(), + ) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Memories service unavailable" + ) + else: + try: + memory = await self._repository.update_content( + memory=memory, + content=content.model_dump(), + ) + await self._session.commit() + await self._session.refresh(memory) + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException( + status_code=503, detail="Memories service unavailable" + ) + + logger.info( + "work_memory_updated", + extra={"user_id": str(user_id)}, ) - async def get_agent_memories(self, *, owner_id: UUID) -> MemoryListResponse: - memories = await self._repository.get_active_memories(owner_id=owner_id) - agent_memories = [ - self._to_context(memory) - for memory in memories - if memory.memory_type.value == "work" - ] - return MemoryListResponse( - owner_id=owner_id, memories=agent_memories, total=len(agent_memories) + return self._parse_work_content(memory) + + async def patch_user_memory( + self, *, content: UserMemoryContent | None = None + ) -> UserMemoryContent: + user_id = self.require_user_id() + + try: + memory = await self._repository.get_user_memory_for_owner(owner_id=user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + if memory is None: + raise HTTPException(status_code=404, detail="User memory not found") + + try: + update_data: dict = {} + if content is not None: + existing_content = memory.content or {} + merged = content.model_dump() + existing_content.update( + {k: v for k, v in merged.items() if v is not None} + ) + update_data["content"] = existing_content + + if update_data: + memory = await self._repository.update_content( + memory=memory, + content=update_data.get("content"), + ) + await self._session.commit() + await self._session.refresh(memory) + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException(status_code=503, detail="Memories service unavailable") + + logger.info( + "user_memory_patched", + extra={"user_id": str(user_id)}, ) - async def get_all_memories(self, *, owner_id: UUID) -> MemoryListResponse: - memories = await self._repository.get_active_memories(owner_id=owner_id) - memory_contexts = [self._to_context(memory) for memory in memories] - return MemoryListResponse( - owner_id=owner_id, memories=memory_contexts, total=len(memory_contexts) + return self._parse_user_content(memory) + + async def patch_work_memory( + self, *, content: WorkProfileContent | None = None + ) -> WorkProfileContent: + user_id = self.require_user_id() + + try: + memory = await self._repository.get_work_memory_for_owner(owner_id=user_id) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") + + if memory is None: + raise HTTPException(status_code=404, detail="Work memory not found") + + try: + update_data: dict = {} + if content is not None: + existing_content = memory.content or {} + merged = content.model_dump() + existing_content.update( + {k: v for k, v in merged.items() if v is not None} + ) + update_data["content"] = existing_content + + if update_data: + memory = await self._repository.update_content( + memory=memory, + content=update_data.get("content"), + ) + await self._session.commit() + await self._session.refresh(memory) + except SQLAlchemyError: + await self._session.rollback() + raise HTTPException(status_code=503, detail="Memories service unavailable") + + logger.info( + "work_memory_patched", + extra={"user_id": str(user_id)}, ) + + return self._parse_work_content(memory) + + def _parse_user_content(self, memory: Memory) -> UserMemoryContent: + content_dict = memory.content or {} + return UserMemoryContent.model_validate(content_dict) + + def _parse_work_content(self, memory: Memory) -> WorkProfileContent: + content_dict = memory.content or {} + return WorkProfileContent.model_validate(content_dict) + + async def get_memory_model(self, *, memory_type: MemoryType) -> Memory | None: + user_id = self.require_user_id() + try: + return await self._repository.get_by_type_for_owner( + owner_id=user_id, + memory_type=memory_type, + ) + except SQLAlchemyError: + raise HTTPException(status_code=503, detail="Memories service unavailable") diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index db242a5..2ec6656 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -7,6 +7,7 @@ from v1.app.router import router as app_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 +from v1.memories.router import router as memories_router from v1.schedule_items.router import router as schedule_items_router from v1.todo.router import router as todo_router from v1.users.router import router as users_router @@ -16,8 +17,8 @@ router = APIRouter(prefix="/api/v1") router.include_router(app_router) router.include_router(auth_router) router.include_router(agent_router) -router.include_router(agent_router) router.include_router(friendships_router) +router.include_router(memories_router) router.include_router(users_router) router.include_router(schedule_items_router) router.include_router(inbox_messages_router) diff --git a/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py b/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py index 9ce5669..1f1d48d 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py +++ b/backend/tests/unit/core/agentscope/runtime/test_orchestrator.py @@ -6,7 +6,7 @@ import pytest from ag_ui.core import RunAgentInput from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator -from schemas.automation import MemoryContextConfig, RuntimeConfig +from schemas.automation import MessageContextConfig, RuntimeConfig from schemas.user import UserContext, parse_profile_settings @@ -51,7 +51,7 @@ def _run_input() -> RunAgentInput: def _runtime_config() -> RuntimeConfig: return RuntimeConfig( enabled_tools=[], - context=MemoryContextConfig(), + context=MessageContextConfig(), ) diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index acc7f21..cd3c743 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -18,7 +18,7 @@ from schemas.agent.runtime_models import ( WorkerAgentOutputLite, ) from schemas.agent.system_agent import AgentType -from schemas.automation import MemoryContextConfig, RuntimeConfig +from schemas.automation import MessageContextConfig, RuntimeConfig from schemas.user import UserContext, parse_profile_settings @@ -48,7 +48,7 @@ def _user_context() -> UserContext: def _runtime_config() -> RuntimeConfig: return RuntimeConfig( enabled_tools=[], - context=MemoryContextConfig(), + context=MessageContextConfig(), ) diff --git a/backend/tests/unit/core/agentscope/runtime/test_tasks.py b/backend/tests/unit/core/agentscope/runtime/test_tasks.py index f2b697a..446fe0f 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_tasks.py +++ b/backend/tests/unit/core/agentscope/runtime/test_tasks.py @@ -7,7 +7,7 @@ import pytest import core.agentscope.runtime.tasks as tasks_module from schemas.agent import ToolStatus -from schemas.automation import ContextWindowMode, MemoryContextConfig +from schemas.automation import ContextWindowMode, MessageContextConfig from schemas.user import UserContext, parse_profile_settings @@ -201,7 +201,7 @@ async def test_build_recent_context_messages_includes_all_user_attachments( self, *, thread_id: str, - context_config: MemoryContextConfig, + context_config: MessageContextConfig, ) -> dict[str, object] | None: del thread_id, context_config return { @@ -237,7 +237,7 @@ async def test_build_recent_context_messages_includes_all_user_attachments( messages = await tasks_module._build_recent_context_messages( session=object(), thread_id=str(uuid4()), - context_config=MemoryContextConfig( + context_config=MessageContextConfig( window_mode=ContextWindowMode.DAY, window_count=2, ), @@ -264,7 +264,7 @@ async def test_build_recent_context_messages_uses_tool_metadata_output( self, *, thread_id: str, - context_config: MemoryContextConfig, + context_config: MessageContextConfig, ) -> dict[str, object] | None: del thread_id, context_config return { @@ -295,7 +295,7 @@ async def test_build_recent_context_messages_uses_tool_metadata_output( messages = await tasks_module._build_recent_context_messages( session=object(), thread_id=str(uuid4()), - context_config=MemoryContextConfig(), + context_config=MessageContextConfig(), ) assert len(messages) == 1 @@ -319,7 +319,7 @@ async def test_build_recent_context_messages_skips_tool_without_metadata_output( self, *, thread_id: str, - context_config: MemoryContextConfig, + context_config: MessageContextConfig, ) -> dict[str, object] | None: del thread_id, context_config return { @@ -337,7 +337,7 @@ async def test_build_recent_context_messages_skips_tool_without_metadata_output( messages = await tasks_module._build_recent_context_messages( session=object(), thread_id=str(uuid4()), - context_config=MemoryContextConfig(), + context_config=MessageContextConfig(), ) assert messages == [] @@ -357,7 +357,7 @@ async def test_build_recent_context_messages_passes_context_config( self, *, thread_id: str, - context_config: MemoryContextConfig, + context_config: MessageContextConfig, ) -> dict[str, object] | None: del thread_id captured_config["config"] = context_config @@ -365,7 +365,7 @@ async def test_build_recent_context_messages_passes_context_config( monkeypatch.setattr(tasks_module, "AgentContextService", _FakeContextService) - cfg = MemoryContextConfig(window_mode=ContextWindowMode.NUMBER, window_count=10) + cfg = MessageContextConfig(window_mode=ContextWindowMode.NUMBER, window_count=10) messages = await tasks_module._build_recent_context_messages( session=object(), thread_id=str(uuid4()), diff --git a/backend/tests/unit/core/agentscope/test_memory_tools.py b/backend/tests/unit/core/agentscope/test_memory_tools.py new file mode 100644 index 0000000..aa2d0e1 --- /dev/null +++ b/backend/tests/unit/core/agentscope/test_memory_tools.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from agentscope.tool import ToolResponse + +from core.agentscope.tools.custom import memory as memory_module +from models.memories import MemoryType +from schemas.memories.memory_content import UserMemoryContent + + +def _decode_tool_response(response: ToolResponse) -> dict[str, object]: + assert response.content + first = response.content[0] + text = str(first.get("text", "")) if isinstance(first, dict) else str(first.text) + return json.loads(text) + + +def _payload_error_code(payload: dict[str, object]) -> str: + error = payload.get("error") + if not isinstance(error, dict): + return "" + return str(error.get("code") or "") + + +class _FakeMemoriesService: + def __init__(self) -> None: + self.memory: object | None = None + self.updated_user = 0 + self.updated_work = 0 + + async def get_memory_model(self, *, memory_type: MemoryType): + _ = memory_type + return self.memory + + async def update_user_memory(self, **kwargs): + _ = kwargs + self.updated_user += 1 + return SimpleNamespace() + + async def update_work_memory(self, **kwargs): + _ = kwargs + self.updated_work += 1 + return SimpleNamespace() + + +def _user_memory(): + return SimpleNamespace( + id=uuid4(), + owner_id=uuid4(), + memory_type=MemoryType.USER, + content={"preferences": {"communication_style": "简洁"}}, + status="active", + ) + + +@pytest.mark.asyncio +async def test_memory_write_requires_runtime_context() -> None: + response = await memory_module.memory_write( + memory_type="user", + user_content=UserMemoryContent(interests=["跑步"]), + ) + payload = _decode_tool_response(response) + assert payload["status"] == "failure" + assert _payload_error_code(payload) == "MISSING_RUNTIME_ARGS" + + +@pytest.mark.asyncio +async def test_memory_write_updates_user_content( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeMemoriesService() + monkeypatch.setattr( + memory_module, "create_memories_service", lambda **_: fake_service + ) + + response = await memory_module.memory_write( + memory_type="user", + user_content=UserMemoryContent(interests=["阅读"]), + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(response) + + assert payload["status"] == "success" + assert "memory_type=user" in str(payload["result"]) + assert fake_service.updated_user == 1 + + +@pytest.mark.asyncio +async def test_memory_forget_updates_content_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeMemoriesService() + fake_service.memory = _user_memory() + monkeypatch.setattr( + memory_module, "create_memories_service", lambda **_: fake_service + ) + + response = await memory_module.memory_forget( + memory_type="user", + forget_paths=["preferences.communication_style"], + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(response) + + assert payload["status"] == "success" + assert "forgotten=1" in str(payload["result"]) + assert fake_service.updated_user == 1 diff --git a/backend/tests/unit/core/agentscope/test_system_prompt.py b/backend/tests/unit/core/agentscope/test_system_prompt.py index 84a4ca3..6328c58 100644 --- a/backend/tests/unit/core/agentscope/test_system_prompt.py +++ b/backend/tests/unit/core/agentscope/test_system_prompt.py @@ -158,46 +158,44 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication assert "Follow agent contracts strictly" not in prompt -def test_build_system_prompt_includes_memory_section_when_memories_provided() -> None: - from schemas.memories import ( - MemoryContext, - MemoryListResponse, - MemorySource, - MemoryType, +def test_build_system_prompt_includes_user_memory_section_for_router() -> None: + from schemas.memories.memory_content import UserMemoryContent + + user_memory = UserMemoryContent() + + prompt = build_system_prompt( + agent_type=AgentType.ROUTER, + user_context=_build_user_context(), + now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), + user_memory=user_memory, ) - memories = MemoryListResponse( - owner_id=uuid4(), - memories=[ - MemoryContext( - memory_type=MemoryType.USER, - source=MemorySource.MANUAL, - title="User prefers morning meetings", - content={"text": "User likes meetings before 10am"}, - created_at=datetime(2026, 3, 1, tzinfo=timezone.utc), - updated_at=datetime(2026, 3, 1, tzinfo=timezone.utc), - ), - ], - total=1, - ) + assert "" in prompt + assert "[User Memory]" in prompt + + +def test_build_system_prompt_includes_work_memory_section_for_worker() -> None: + from schemas.memories.memory_content import WorkProfileContent + + work_memory = WorkProfileContent() prompt = build_system_prompt( agent_type=AgentType.WORKER, user_context=_build_user_context(), now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), - memories=memories, + work_memory=work_memory, ) - assert "" in prompt - assert "[User Memories]" in prompt - assert "User prefers morning meetings" in prompt + assert "" in prompt + assert "[Work Memory]" in prompt def test_build_system_prompt_omits_memory_section_when_no_memories() -> None: prompt = build_system_prompt( - agent_type=AgentType.WORKER, + agent_type=AgentType.ROUTER, user_context=_build_user_context(), now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), ) - assert "" not in prompt + assert "" not in prompt + assert "" not in prompt diff --git a/backend/tests/unit/core/agentscope/test_toolkit.py b/backend/tests/unit/core/agentscope/test_toolkit.py index 7123d4f..ce6ead1 100644 --- a/backend/tests/unit/core/agentscope/test_toolkit.py +++ b/backend/tests/unit/core/agentscope/test_toolkit.py @@ -28,25 +28,3 @@ def test_build_stage_toolkit_uses_explicit_enabled_tools_as_final_set( ) assert captured["enabled_tool_names"] == {"calendar_read", "user_lookup"} - - -def test_build_stage_toolkit_uses_memory_defaults_without_explicit_tools( - monkeypatch, -) -> None: - captured: dict[str, object] = {} - - def _fake_build_toolkit(**kwargs): - captured.update(kwargs) - return object() - - monkeypatch.setattr( - "core.agentscope.tools.toolkit.build_toolkit", _fake_build_toolkit - ) - - build_stage_toolkit( - agent_type=AgentType.MEMORY, - session=cast(Any, object()), - owner_id=uuid4(), - ) - - assert captured["enabled_tool_names"] == {"calendar_read", "user_lookup"} diff --git a/backend/tests/unit/core/agentscope/test_toolkit_registry.py b/backend/tests/unit/core/agentscope/test_toolkit_registry.py index b6e5fae..8d0b88a 100644 --- a/backend/tests/unit/core/agentscope/test_toolkit_registry.py +++ b/backend/tests/unit/core/agentscope/test_toolkit_registry.py @@ -16,13 +16,14 @@ async def test_build_toolkit_registers_calendar_tools() -> None: toolkit = build_toolkit( session=cast(AsyncSession, SimpleNamespace()), owner_id=uuid4(), - user_token="token-123", ) schemas = toolkit.get_json_schemas() names = {item["function"]["name"] for item in schemas} assert "calendar_read" in names assert "calendar_write" in names assert "calendar_share" in names + assert "memory_write" in names + assert "memory_forget" in names write_schema = next( item for item in schemas if item["function"]["name"] == "calendar_write" 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 new file mode 100644 index 0000000..ead5413 --- /dev/null +++ b/backend/tests/unit/core/config/test_memory_automation_static_config.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from v1.auth.automation_static_config import load_static_automation_job_config + + +def test_memory_automation_static_config_contract() -> None: + config = load_static_automation_job_config(config_name="memory_extraction") + + assert config.context.window_mode.value == "day" + assert config.context.window_count == 2 + assert [tool.value for tool in config.enabled_tools] == [ + "memory.write", + "memory.forget", + ] + prompt = config.input_template + assert "提取" in prompt + assert "遗忘" in prompt diff --git a/backend/tests/unit/database/test_automation_job_migration_contract.py b/backend/tests/unit/database/test_automation_job_migration_contract.py index 031c2c4..4f2869c 100644 --- a/backend/tests/unit/database/test_automation_job_migration_contract.py +++ b/backend/tests/unit/database/test_automation_job_migration_contract.py @@ -16,3 +16,18 @@ def test_memory_automation_job_trigger_exists_in_0004_migration() -> None: assert "'agent_type', 'memory'" in content assert "ux_automation_jobs_owner_memory_active" in content assert "input_template" in content + + +def test_bootstrap_key_replaces_agent_type_unique_anchor() -> None: + migration = ( + Path(__file__).resolve().parents[3] + / "alembic" + / "versions" + / "20260323_0003_bootstrap_job_key_and_unique_indexes.py" + ) + content = migration.read_text(encoding="utf-8") + + assert "bootstrap_key" in content + assert "ux_automation_jobs_owner_bootstrap_key_active" in content + assert "ux_memories_owner_memory_type" in content + assert "DROP INDEX IF EXISTS ux_automation_jobs_owner_memory_active" in content diff --git a/backend/tests/unit/v1/auth/test_auth_service.py b/backend/tests/unit/v1/auth/test_auth_service.py index 7daeae2..6ad2478 100644 --- a/backend/tests/unit/v1/auth/test_auth_service.py +++ b/backend/tests/unit/v1/auth/test_auth_service.py @@ -12,6 +12,14 @@ from v1.auth.schemas import ( from v1.auth.service import AuthService, AuthServiceGateway +class FakeRegistrationBootstrapper: + def __init__(self) -> None: + self.called_user_ids: list[str] = [] + + async def ensure_user_automation_jobs(self, *, user_id: str) -> None: + self.called_user_ids.append(user_id) + + class FakeGateway(AuthServiceGateway): def __init__(self, response: SessionResponse) -> None: self._response = response @@ -75,6 +83,27 @@ async def test_create_phone_session_forwards_payload() -> None: assert response.user.phone == "+8613812345678" +@pytest.mark.asyncio +async def test_create_phone_session_bootstraps_automation_job() -> None: + user = AuthUser(id="b196f8be-c5f4-45d8-8f07-65c0ddf4d3de", phone="+8613812345678") + token_response = SessionResponse( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + user=user, + ) + gateway = FakeGateway(token_response) + bootstrapper = FakeRegistrationBootstrapper() + service = AuthService(gateway=gateway, registration_bootstrapper=bootstrapper) + + await service.create_phone_session( + PhoneSessionCreateRequest(phone="+8613812345678", token="123456") + ) + + assert bootstrapper.called_user_ids == ["b196f8be-c5f4-45d8-8f07-65c0ddf4d3de"] + + @pytest.mark.asyncio async def test_refresh_session_forwards_payload() -> None: user = AuthUser(id="user-1", phone="+8613812345678") diff --git a/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py b/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py new file mode 100644 index 0000000..c8bdf41 --- /dev/null +++ b/backend/tests/unit/v1/auth/test_registration_bootstrap_service.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, cast +from uuid import uuid4 + +import pytest + +from v1.auth.registration_bootstrap import ( + compute_next_local_time_utc, +) + + +def test_compute_next_local_time_utc_from_asia_shanghai() -> None: + now_utc = datetime(2026, 3, 23, 0, 30, tzinfo=timezone.utc) + + run_at, next_run_at = compute_next_local_time_utc( + now_utc=now_utc, + timezone_name="Asia/Shanghai", + local_hour=8, + local_minute=0, + ) + + assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) + assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc) + + +def test_compute_next_local_time_utc_rolls_to_next_day_when_passed() -> None: + now_utc = datetime(2026, 3, 23, 2, 30, tzinfo=timezone.utc) + + run_at, next_run_at = compute_next_local_time_utc( + now_utc=now_utc, + timezone_name="Asia/Shanghai", + local_hour=8, + local_minute=0, + ) + + assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc) + assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc) + + +@pytest.mark.asyncio +async def test_registration_service_is_idempotent_when_job_exists() -> None: + from v1.auth.registration_bootstrap import RegistrationAutomationBootstrapService + + expected_owner_id = uuid4() + + class _Repo: + inserted = 0 + upsert_calls = 0 + + async def get_profile_timezone(self, *, user_id): + assert user_id == expected_owner_id + return "Asia/Shanghai" + + async def insert_bootstrap_automation_job_if_absent(self, **kwargs): + assert kwargs["owner_id"] == expected_owner_id + assert kwargs["bootstrap_key"] == "memory_extraction" + self.inserted += 1 + return False + + async def upsert_initial_memory(self, **kwargs): + self.upsert_calls += 1 + return False + + class _Session: + async def commit(self): + raise AssertionError("must not commit when already exists") + + async def rollback(self): + raise AssertionError("must not rollback when no error") + + service = RegistrationAutomationBootstrapService( + repository=cast(Any, _Repo()), session=cast(Any, _Session()) + ) + await service.ensure_user_automation_jobs(user_id=str(expected_owner_id)) + + +@pytest.mark.asyncio +async def test_registration_service_creates_initial_memories_when_missing() -> None: + from v1.auth.registration_bootstrap import RegistrationAutomationBootstrapService + + expected_owner_id = uuid4() + + class _Repo: + async def get_profile_timezone(self, *, user_id): + assert user_id == expected_owner_id + return "Asia/Shanghai" + + async def upsert_initial_memory(self, **kwargs): + return True + + async def insert_bootstrap_automation_job_if_absent(self, **kwargs): + _ = kwargs + return True + + class _Session: + committed = 0 + + async def commit(self): + self.committed += 1 + + async def rollback(self): + raise AssertionError("must not rollback when no error") + + session = _Session() + service = RegistrationAutomationBootstrapService( + repository=cast(Any, _Repo()), session=cast(Any, session) + ) + await service.ensure_user_automation_jobs(user_id=str(expected_owner_id)) + + assert session.committed == 1 diff --git a/docs/plans/2026-03-23-memories-ui-implementation.md b/docs/plans/2026-03-23-memories-ui-implementation.md new file mode 100644 index 0000000..b669370 --- /dev/null +++ b/docs/plans/2026-03-23-memories-ui-implementation.md @@ -0,0 +1,231 @@ +# Memories 界面实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现前端memories界面,卡片列表展示user和work记忆,支持查看详情和更新 + +**Architecture:** +- 使用现有ApiClient调用后端 `/api/v1/memories` 接口 +- 数据模型解析UserMemoryContent和WorkProfileContent +- 遵循项目视觉设计语言(soft blue, layered card-based) +- 通过GoRouter管理导航 + +**Tech Stack:** Flutter, Dio, GoRouter, GetIt依赖注入 + +--- + +### Task 1: 创建Memory数据模型 + +**Files:** +- Create: `apps/lib/features/settings/data/models/memory_models.dart` + +**Step 1: 创建数据模型文件** + +```dart +// UserMemoryContent - 用户记忆 +class UserMemoryContent { + final String? occupation; + final String? timezone; + final String? primaryLanguage; + final List people; + final List places; + final UserPreferences preferences; + final SchedulingPreferences schedulingPreferences; + final List interests; + final List avoidTopics; + final List customRules; + final List recurringRoutines; + + // ... 工厂方法 fromJson +} + +class Person { + final String name; + final String? relationship; + final String? role; + final String? preferredContactChannel; + final String? notes; + final PersonMeta? meta; +} + +class Place { + final String name; + final String? category; + final String? address; + final String? timezone; + final int? commuteMinutes; + final String? preference; + final String? notes; + final PlaceMeta? meta; +} + +// WorkProfileContent - 工作记忆 +class WorkProfileContent { + final String? occupation; + final List expertise; + final List preferredTools; + final List currentProjects; + final WorkHabits workHabits; + final List teamMembers; + final String? teamContext; + final List workRules; +} + +// MemoryListResponse - API响应 +class MemoryListResponse { + final UserMemoryContent? userMemory; + final WorkProfileContent? workMemory; +} +``` + +--- + +### Task 2: 更新MemoryService调用真实API + +**Files:** +- Modify: `apps/lib/features/settings/data/services/memory_service.dart` + +**Step 1: 重写MemoryService** + +```dart +import 'package:social_app/core/api/i_api_client.dart'; +import '../models/memory_models.dart'; + +class MemoryService { + final IApiClient _client; + static const _prefix = '/api/v1/memories'; + + MemoryService(this._client); + + Future getAllMemories() async { ... } + Future getUserMemory() async { ... } + Future getWorkMemory() async { ... } + Future updateUserMemory(UserMemoryContent content) async { ... } + Future updateWorkMemory(WorkProfileContent content) async { ... } + Future patchUserMemory(Map content) async { ... } + Future patchWorkMemory(Map content) async { ... } +} +``` + +--- + +### Task 3: 在injection.dart中注册MemoryService + +**Files:** +- Modify: `apps/lib/core/di/injection.dart:26` - 添加import +- Modify: `apps/lib/core/di/injection.dart` - 注册MemoryService + +--- + +### Task 4: 重新设计MemoryScreen主页面 + +**Files:** +- Modify: `apps/lib/features/settings/ui/screens/memory_screen.dart` + +**设计要点:** +- 顶部启用记忆开关卡片 +- 两个主要卡片: User Memory (用户记忆) 和 Work Memory (工作记忆) +- 每个卡片显示关键摘要信息 +- 点击卡片进入对应详情页 +- 底部"管理记忆条目"按钮可点击展开更多信息 +- 遵循soft blue品牌、layered card-based界面 + +**卡片内容:** +- User Memory: 显示 occupation, people数量, places数量, interests数量 +- Work Memory: 显示 occupation, expertise数量, currentProjects数量, teamMembers数量 + +--- + +### Task 5: 创建UserMemoryDetailScreen + +**Files:** +- Create: `apps/lib/features/settings/ui/screens/user_memory_detail_screen.dart` + +**功能:** +- 显示完整的UserMemoryContent信息 +- 支持编辑和更新 +- 分组展示: 基本信息、联系人、地点、偏好设置、日程偏好、兴趣、回避话题等 +- 使用可折叠的Section展示 + +--- + +### Task 6: 创建WorkMemoryDetailScreen + +**Files:** +- Create: `apps/lib/features/settings/ui/screens/work_memory_detail_screen.dart` + +**功能:** +- 显示完整的WorkProfileContent信息 +- 支持编辑和更新 +- 分组展示: 基本信息、项目、团队成员、工作习惯等 +- 使用可折叠的Section展示 + +--- + +### Task 7: 添加路由配置 + +**Files:** +- Modify: `apps/lib/core/router/app_router.dart:26` - 添加import +- Modify: `apps/lib/core/router/app_router.dart` - 添加路由 +- Modify: `apps/lib/core/router/app_routes.dart` - 添加路由常量 + +**新增路由:** +- `/settings/memory/user` - UserMemoryDetailScreen +- `/settings/memory/work` - WorkMemoryDetailScreen + +--- + +### Task 8: 更新MemoryScreen导航 + +**Files:** +- Modify: `apps/lib/features/settings/ui/screens/memory_screen.dart` + +**Step 1: 添加导航跳转** + +```dart +// 点击User Memory卡片 +context.push('/settings/memory/user'); + +// 点击Work Memory卡片 +context.push('/settings/memory/work'); +``` + +--- + +### Task 9: 验证和测试 + +**Step 1: 运行flutter analyze检查代码质量** + +```bash +cd apps && flutter analyze +``` + +**Step 2: 验证设计token使用正确** + +检查所有颜色、间距、圆角都来自design_tokens.dart + +**Step 3: 验证API调用正确** + +确认使用正确的endpoint和HTTP方法 + +--- + +### 关键文件路径参考 + +- Design tokens: `apps/lib/core/theme/design_tokens.dart` +- Visual design language: `apps/rules/visual_design_language.md` +- Settings scaffold: `apps/lib/features/settings/ui/widgets/settings_page_scaffold.dart` +- API client: `apps/lib/core/api/api_client.dart` +- 后端router: `backend/src/v1/memories/router.py` +- Protocol文档: `docs/protocols/models/memory.md` + +--- + +### 视觉设计要点 + +1. **Surface层次**: Background → Primary cards → Secondary grouped surfaces +2. **Color**: 使用AppColors.blue系列作为品牌色,避免过度使用 +3. **Spacing**: 使用AppSpacing (xs=4, sm=8, md=12, lg=16, xl=20, xxl=24) +4. **Radius**: 使用AppRadius (sm=6, md=12, lg=16, xl=18, xxl=24) +5. **Motion**: 150-300ms micro-interactions, soft press feedback +6. **卡片阴影**: 使用soft shadow如 `blurRadius: 8, offset: (0, 2)` diff --git a/docs/plans/2026-03-23-memory-system-design.md b/docs/plans/2026-03-23-memory-system-design.md new file mode 100644 index 0000000..e4f069f --- /dev/null +++ b/docs/plans/2026-03-23-memory-system-design.md @@ -0,0 +1,78 @@ +# Memory System Design + +## Overview + +每用户有两条记忆记录(user_type 和 work_type),存储在 `memories` 表的 `content` JSONB 字段中。 + +## Data Model + +### UserMemoryContent + +```python +class UserPreferences(BaseModel): + time_preference: str | None = None # "上午效率高" + communication_style: str | None = None # "简洁直接" + location_preference: str | None = None # "喜欢远程工作" + work_lifestyle: str | None = None # "早睡早起" + +class UserMemoryContent(BaseModel): + occupation: str | None = None # 职业 + timezone: str | None = None # 时区 + language: str | None = None # 语言偏好 + people: list[str] = [] # 重要人物 + places: list[str] = [] # 常去地点 + projects: list[str] = [] # 个人项目 + preferences: UserPreferences = UserPreferences() + interests: list[str] = [] # 兴趣爱好 + avoid_topics: list[str] = [] # 不想讨论的话题 + custom_rules: list[str] = [] # 自定义规则 + recurring_contexts: list[str] = [] # 周期性场景 +``` + +### WorkMemoryContent + +```python +class WorkProject(BaseModel): + name: str + description: str | None = None + status: str | None = None # "active", "paused" + key_milestones: list[str] = [] # 关键里程碑 + +class WorkHabit(BaseModel): + available_hours: dict[str, str] = {} # {"monday": "09:00-18:00"} + deep_work_blocks: list[str] = [] # 深度工作时段 + meeting_preference: str | None = None # "short", "30min最佳" + notification_channel: str | None = None # 首选沟通渠道 + +class WorkMemoryContent(BaseModel): + expertise: list[str] = [] # 专业领域 + preferred_tools: list[str] = [] # 惯用工具 + current_projects: list[WorkProject] = [] + work_habits: WorkHabit = WorkHabit() + team_members: list[str] = [] # 团队成员 + team_context: str | None = None # 团队概述 +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/memories` | 获取用户所有记忆 | +| PUT | `/api/v1/memories/user` | 创建/更新用户记忆 | +| PUT | `/api/v1/memories/work` | 创建/更新工作记忆 | +| DELETE | `/api/v1/memories/{type}` | 删除指定类型记忆 | + +## TaskType支撑关系 + +| TaskType | Router决策依赖 | Worker执行依赖 | +|----------|---------------|---------------| +| SCHEDULING | timezone, available_hours | current_projects, team_members | +| PLANNING | preferences, work_lifestyle | expertise, work_habits | +| COMMUNICATION_DRAFTING | communication_style, avoid_topics | team_context, preferred_tools | +| RECOMMENDATION | interests, people | expertise, current_projects | + +## Frontend UI + +- MemoryScreen: 主列表页,展示user/work两条记忆卡片 +- MemoryDetailScreen: 详情/编辑页,分Tab展示各字段 +- 支持新建/编辑/删除操作 diff --git a/docs/protocols/calendar/schedule-items.md b/docs/protocols/calendar/schedule-items.md new file mode 100644 index 0000000..a6547b0 --- /dev/null +++ b/docs/protocols/calendar/schedule-items.md @@ -0,0 +1,262 @@ +# Schedule Items 协议 + +本文档定义 `/api/v1/schedule-items` 的日历日程管理协议。 + +Base URL: `/api/v1/schedule-items` + +--- + +## 端点 + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `` | 创建日程 | +| GET | `` | 按日期范围查询日程 | +| GET | `/{item_id}` | 获取指定日程详情 | +| PATCH | `/{item_id}` | 更新日程 | +| DELETE | `/{item_id}` | 删除日程 | +| POST | `/{item_id}/share` | 分享日程给其他用户 | +| POST | `/{item_id}/accept` | 接受日程订阅 | +| POST | `/{item_id}/reject` | 拒绝日程订阅 | + +--- + +## 枚举类型 + +### ScheduleItemStatus + +| 值 | 说明 | +|---|---| +| `active` | 进行中 | +| `completed` | 已完成 | +| `canceled` | 已取消 | +| `archived` | 已归档 | + +### ScheduleItemSourceType + +| 值 | 说明 | +|---|---| +| `manual` | 手动创建 | +| `imported` | 导入 | +| `agent_generated` | Agent 生成 | + +### AttachmentType + +| 值 | 说明 | +|---|---| +| `document` | 文档 | +| `reminder` | 提醒 | + +--- + +## 数据结构 + +### ScheduleItemMetadata + +```json +{ + "color": "#FF5733 | null", + "location": "string | null", + "notes": "string | null", + "attachments": [ + { + "name": "string", + "type": "document | reminder", + "visible_to": ["uuid"], + "url": "string | null", + "note": "string | null", + "content": "string | null" + } + ], + "reminder_minutes": "int (0-10080) | null", + "version": 1 +} +``` + +### ScheduleItemCreateRequest + +```json +{ + "title": "string (1-255)", + "description": "string | null (max 2000)", + "start_at": "datetime (必须包含时区)", + "end_at": "datetime | null (必须包含时区)", + "timezone": "string (IANA 时区)", + "metadata": "ScheduleItemMetadata | null" +} +``` + +### ScheduleItemUpdateRequest + +```json +{ + "title": "string | null (1-255)", + "description": "string | null (max 2000)", + "start_at": "datetime | null (必须包含时区)", + "end_at": "datetime | null (必须包含时区)", + "timezone": "string | null (IANA 时区)", + "metadata": "ScheduleItemMetadata | null", + "status": "ScheduleItemStatus | null" +} +``` + +### ScheduleItemResponse + +```json +{ + "id": "uuid", + "owner_id": "uuid", + "title": "string", + "description": "string | null", + "start_at": "datetime", + "end_at": "datetime | null", + "timezone": "string", + "metadata": "ScheduleItemMetadata | null", + "status": "ScheduleItemStatus", + "source_type": "ScheduleItemSourceType", + "created_at": "datetime", + "updated_at": "datetime", + "permission": "int", + "is_owner": "boolean" +} +``` + +### ScheduleItemShareRequest + +```json +{ + "phone": "+8613812345678", + "permission_view": "boolean (default: true)", + "permission_edit": "boolean (default: false)", + "permission_invite": "boolean (default: false)" +} +``` + +### ScheduleItemShareResponse + +```json +{ + "message": "string" +} +``` + +--- + +## 1) POST `/` + +创建日程。 + +### Request + +`ScheduleItemCreateRequest` 对象。 + +### Response + +`ScheduleItemResponse` 对象,状态码 201。 + +--- + +## 2) GET `/` + +按日期范围查询日程。 + +### Query Parameters + +- `start_at`: 开始时间(必须包含时区) +- `end_at`: 结束时间(必须包含时区) + +### Response + +`ScheduleItemResponse` 对象数组。 + +--- + +## 3) GET `/{item_id}` + +获取指定日程详情。 + +### Path Parameters + +- `item_id`: 日程 UUID + +### Response + +`ScheduleItemResponse` 对象。 + +--- + +## 4) PATCH `/{item_id}` + +更新日程(部分更新)。 + +### Path Parameters + +- `item_id`: 日程 UUID + +### Request + +`ScheduleItemUpdateRequest` 对象。 + +### Response + +`ScheduleItemResponse` 对象。 + +--- + +## 5) DELETE `/{item_id}` + +删除日程。 + +### Path Parameters + +- `item_id`: 日程 UUID + +### Response + +204 No Content。 + +--- + +## 6) POST `/{item_id}/share` + +分享日程给其他用户。 + +### Path Parameters + +- `item_id`: 日程 UUID + +### Request + +`ScheduleItemShareRequest` 对象。 + +### Response + +`ScheduleItemShareResponse` 对象。 + +--- + +## 7) POST `/{item_id}/accept` + +接受日程订阅邀请。 + +### Path Parameters + +- `item_id`: 日程 UUID + +### Response + +字典对象。 + +--- + +## 8) POST `/{item_id}/reject` + +拒绝日程订阅邀请。 + +### Path Parameters + +- `item_id`: 日程 UUID + +### Response + +字典对象。 diff --git a/docs/protocols/models/friendships.md b/docs/protocols/models/friendships.md new file mode 100644 index 0000000..af4bfb0 --- /dev/null +++ b/docs/protocols/models/friendships.md @@ -0,0 +1,188 @@ +# Friendships 协议 + +本文档定义 `/api/v1/friends` 的好友申请与管理协议。 + +Base URL: `/api/v1/friends` + +--- + +## 端点 + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/requests` | 发送好友申请 | +| GET | `/requests/inbox` | 获取收到的好友申请列表 | +| GET | `/requests/outgoing` | 获取发出的好友申请列表 | +| GET | `/requests/{friendship_id}` | 获取指定好友申请详情 | +| POST | `/requests/{friendship_id}/accept` | 接受好友申请 | +| POST | `/requests/{friendship_id}/decline` | 拒绝好友申请 | +| DELETE | `/requests/{friendship_id}` | 取消发出的好友申请 | +| GET | `` | 获取好友列表 | +| DELETE | `/{friendship_id}` | 删除好友 | + +--- + +## 数据结构 + +### FriendRequestCreate + +```json +{ + "target_user_id": "uuid", + "content": "string | null" +} +``` + +- `target_user_id`: 目标用户 ID +- `content`: 申请消息,最大 200 字符 + +### FriendRequestResponse + +```json +{ + "id": "uuid", + "sender": { /* UserContext */ }, + "recipient": { /* UserContext */ }, + "content": { "type": "request", "message": "string | null" } | null, + "status": "pending | accepted | rejected | canceled", + "created_at": "datetime" +} +``` + +### FriendResponse + +```json +{ + "id": "uuid", + "friend": { /* UserContext */ }, + "status": "active", + "created_at": "datetime", + "accepted_at": "datetime | null" +} +``` + +--- + +## 1) POST `/requests` + +发送好友申请。 + +### Request + +`FriendRequestCreate` 对象。 + +### Response + +`FriendRequestResponse` 对象,状态码 201。 + +--- + +## 2) GET `/requests/inbox` + +获取收到的待处理好友申请列表。 + +### Request + +无请求体。 + +### Response + +`FriendRequestResponse` 对象数组,状态为 `pending`。 + +--- + +## 3) GET `/requests/outgoing` + +获取发出的好友申请列表。 + +### Request + +无请求体。 + +### Response + +`FriendRequestResponse` 对象数组。 + +--- + +## 4) GET `/requests/{friendship_id}` + +获取指定好友申请详情。 + +### Path Parameters + +- `friendship_id`: 好友申请 UUID + +### Response + +`FriendRequestResponse` 对象。 + +--- + +## 5) POST `/requests/{friendship_id}/accept` + +接受好友申请。 + +### Path Parameters + +- `friendship_id`: 好友申请 UUID + +### Response + +`FriendRequestResponse` 对象,状态更新为 `accepted`。 + +--- + +## 6) POST `/requests/{friendship_id}/decline` + +拒绝好友申请。 + +### Path Parameters + +- `friendship_id`: 好友申请 UUID + +### Response + +`FriendRequestResponse` 对象,状态更新为 `rejected`。 + +--- + +## 7) DELETE `/requests/{friendship_id}` + +取消发出的好友申请。 + +### Path Parameters + +- `friendship_id`: 好友申请 UUID + +### Response + +204 No Content。 + +--- + +## 8) GET `/` + +获取好友列表。 + +### Request + +无请求体。 + +### Response + +`FriendResponse` 对象数组,状态为 `active`。 + +--- + +## 9) DELETE `/{friendship_id}` + +删除好友。 + +### Path Parameters + +- `friendship_id`: 好友关系 UUID + +### Response + +204 No Content。 diff --git a/docs/protocols/models/inbox-messages.md b/docs/protocols/models/inbox-messages.md new file mode 100644 index 0000000..af09c3a --- /dev/null +++ b/docs/protocols/models/inbox-messages.md @@ -0,0 +1,128 @@ +# Inbox Messages 协议 + +本文档定义 `/api/v1/inbox/messages` 的收件箱消息协议。 + +Base URL: `/api/v1/inbox/messages` + +--- + +## 端点 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `` | 获取消息列表 | +| PATCH | `/{message_id}/read` | 标记消息为已读 | + +--- + +## 枚举类型 + +### InboxMessageType + +| 值 | 说明 | +|---|---| +| `friend_request` | 好友申请 | +| `calendar` | 日历消息 | +| `system` | 系统消息 | +| `group` | 群组消息 | + +### InboxMessageStatus + +| 值 | 说明 | +|---|---| +| `pending` | 待处理 | +| `accepted` | 已接受 | +| `rejected` | 已拒绝 | +| `dismissed` | 已忽略 | + +--- + +## 消息内容类型 + +### CalendarInviteContent + +```json +{ + "type": "invite", + "permission": "int (1=view, 4=edit, 8=invite)", + "action": "pending" +} +``` + +### CalendarUpdateContent + +```json +{ + "type": "update", + "title": "string", + "action": "updated" +} +``` + +### CalendarDeleteContent + +```json +{ + "type": "delete", + "title": "string", + "action": "deleted" +} +``` + +### FriendshipContent + +```json +{ + "type": "request", + "message": "string | null" +} +``` + +--- + +## 数据结构 + +### InboxMessageResponse + +```json +{ + "id": "uuid", + "recipient_id": "uuid", + "sender_id": "uuid | null", + "message_type": "InboxMessageType", + "schedule_item_id": "uuid | null", + "friendship_id": "uuid | null", + "content": "CalendarInviteContent | CalendarUpdateContent | CalendarDeleteContent | FriendshipContent | null", + "is_read": "boolean", + "status": "InboxMessageStatus", + "created_at": "datetime" +} +``` + +--- + +## 1) GET `/` + +获取消息列表。 + +### Query Parameters + +- `is_read`: boolean | null(可选,筛选已读/未读状态) + +### Response + +`InboxMessageResponse` 对象数组。 + +--- + +## 2) PATCH `/{message_id}/read` + +标记指定消息为已读。 + +### Path Parameters + +- `message_id`: 消息 UUID + +### Response + +`InboxMessageResponse` 对象。 diff --git a/docs/protocols/models/memory.md b/docs/protocols/models/memory.md new file mode 100644 index 0000000..bc8573e --- /dev/null +++ b/docs/protocols/models/memory.md @@ -0,0 +1,322 @@ +# Memory 协议 + +本文档定义记忆系统的数据模型、API 协议以及 Agent 中的使用方式。 + +--- + +## 概述 + +记忆系统为每个用户维护两类持久化记忆: + +| 类型 | 用途 | 使用者 | +|------|------|--------| +| `user` | 个人偏好、习惯、人际关系、常去地点等 | Router Agent | +| `work` | 工作项目、团队成员、工作习惯、里程碑等 | Worker Agent | + +每个用户拥有**恰好两条**记忆记录(user_type 和 work_type),存储为 JSONB。 + +注册初始化约定: +- 用户首次注册成功后,后台会自动保证 `user` 与 `work` 两条 memory 记录存在。 +- `content` 初始值分别使用 `UserMemoryContent()` 与 `WorkProfileContent()` 的默认结构。 + +--- + +## 数据模型 + +### MemoryType + +```json +{ + "USER": "user", + "WORK": "work" +} +``` + +### MemoryStatus + +```json +{ + "ACTIVE": "active", + "DISABLED": "disabled" +} +``` + +--- + +## Content 数据结构 + +### UserMemoryContent + +```json +{ + "occupation": "string | null", + "timezone": "string | null", + "primary_language": "string | null", + "people": [ + { + "name": "string", + "relationship": "string | null", + "role": "string | null", + "preferred_contact_channel": "string | null", + "notes": "string | null", + "meta": { + "source": "memory_source | null", + "confidence": 0.0-1.0, + "last_updated_at": "datetime | null" + } + } + ], + "places": [ + { + "name": "string", + "category": "string | null", + "address": "string | null", + "timezone": "string | null", + "commute_minutes": "int | null", + "preference": "like | neutral | avoid | null", + "notes": "string | null", + "meta": { ... } + } + ], + "preferences": { + "communication_style": "string | null", + "language_preference": ["string"], + "location_preference": "string | null", + "work_lifestyle": "string | null", + "notification_preference": ["string"] + }, + "scheduling_preferences": { + "productive_windows": [{ "weekdays": [], "start": "HH:MM", "end": "HH:MM" }], + "preferred_meeting_windows": [], + "no_meeting_windows": [], + "deep_work_windows": [], + "preferred_meeting_duration_minutes": [30, 60], + "meeting_buffer_minutes": "int | null", + "max_meetings_per_day": "int | null", + "notes": "string | null" + }, + "interests": ["string"], + "avoid_topics": ["string"], + "custom_rules": ["string"], + "recurring_routines": [ + { + "name": "string", + "description": "string | null", + "cadence": "string | null", + "time_windows": [], + "importance": "string | null", + "meta": { ... } + } + ] +} +``` + +### WorkProfileContent + +```json +{ + "occupation": "string | null", + "expertise": ["string"], + "preferred_tools": ["string"], + "current_projects": [ + { + "name": "string", + "description": "string | null", + "status": "planned | active | paused | completed | null", + "priority": "string | null", + "deadline": "date | null", + "collaborators": ["string"], + "key_milestones": [ + { + "name": "string", + "due_date": "date | null", + "status": "string | null", + "notes": "string | null" + } + ], + "notes": "string | null", + "meta": { ... } + } + ], + "work_habits": { + "available_hours": [{ "weekdays": [], "start": "HH:MM", "end": "HH:MM" }], + "deep_work_blocks": [], + "preferred_meeting_windows": [], + "no_meeting_windows": [], + "preferred_meeting_duration_minutes": [30, 60], + "notification_channel": "string | null", + "notes": "string | null" + }, + "team_members": [ + { + "name": "string", + "role": "string | null", + "relationship": "string | null", + "preferred_contact_channel": "string | null", + "notes": "string | null", + "meta": { ... } + } + ], + "team_context": "string | null", + "work_rules": ["string"] +} +``` + +--- + +## 数据库存储 + +### memories 表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | UUID | 主键 | +| `owner_id` | UUID | 所有者用户 ID | +| `agent_id` | UUID | 关联 Agent ID(可选) | +| `memory_type` | VARCHAR(20) | 记忆类型枚举(当前含 `user`、`work`,可扩展) | +| `content` | JSONB | UserMemoryContent 或 WorkProfileContent | +| `status` | VARCHAR(20) | ACTIVE / DISABLED | +| `created_at` | TIMESTAMP | 创建时间 | +| `updated_at` | TIMESTAMP | 更新时间 | + +说明: +- `source` 列已移除,不再作为行级来源标记。 +- 来源信息如果需要保留,使用 `content` 内各条目的 `meta.source`(字段级来源)。 +- 唯一性约束:同一 `owner_id` 下 `memory_type` 不能重复(`UNIQUE(owner_id, memory_type)`)。 + +--- + +## API 端点 + +Base URL: `/api/v1/memories` + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 获取用户所有记忆 | +| GET | `/user` | 获取用户记忆 (UserMemoryContent) | +| GET | `/work` | 获取工作记忆 (WorkProfileContent) | +| PUT | `/user` | 全量更新用户记忆 | +| PUT | `/work` | 全量更新工作记忆 | +| PATCH | `/user` | 部分更新用户记忆 | +| PATCH | `/work` | 部分更新工作记忆 | + +### GET `/` + +获取用户所有记忆。 + +**Response:** + +```json +{ + "user_memory": { ...UserMemoryContent }, + "work_memory": { ...WorkProfileContent } +} +``` + +### GET `/user` + +**Response:** `UserMemoryContent | null` + +### GET `/work` + +**Response:** `WorkProfileContent | null` + +### PUT `/user` + +全量更新用户记忆。 + +**Request:** + +```json +{ + "content": { ...UserMemoryContent } +} +``` + +**Response:** `UserMemoryContent` + +### PATCH `/user` + +部分更新用户记忆。 + +**Request:** + +```json +{ + "content": { ...Partial UserMemoryContent } +} +``` + +**Response:** `UserMemoryContent` + +--- + +## Agent 中的使用 + +### 职责分离 + +| Agent | Memory 类型 | Prompt 标记 | +|-------|-------------|-------------| +| Router | UserMemoryContent | `...` | +| Worker | WorkProfileContent | `...` | + +### Prompt 组装流程 + +``` +MemoriesService.get_all_memories() + │ + ├── user_memory: UserMemoryContent | null + │ └── build_user_memory_prompt(user_memory) + │ └── 注入 Router system prompt + │ + └── work_memory: WorkProfileContent | null + └── build_work_memory_prompt(work_memory) + └── 注入 Worker system prompt +``` + +### Router Agent + +Router 负责用户意图识别和任务规划,需要根据用户偏好、习惯、人际关系来更精确地理解用户需求。 + +```python +build_system_prompt( + agent_type=AgentType.ROUTER, + user_memory=user_memory, # 注入用户个人记忆 + ... +) +``` + +### Worker Agent + +Worker 负责执行具体任务,需要根据工作项目、团队成员、工作习惯来更好地完成任务。 + +```python +build_system_prompt( + agent_type=AgentType.WORKER, + work_memory=work_memory, # 注入工作记忆 + ... +) +``` + +--- + +## 实现文件 + +### Backend + +| 文件 | 职责 | +|------|------| +| `src/schemas/memories/memory_content.py` | UserMemoryContent、WorkProfileContent 模型 | +| `src/schemas/memories/__init__.py` | MemoryType、MemoryStatus 枚举 | +| `src/models/memories.py` | SQLAlchemy ORM 模型 | +| `src/v1/memories/router.py` | API 端点 | +| `src/v1/memories/service.py` | 业务逻辑层 | +| `src/v1/memories/repository.py` | 数据访问层 | +| `src/core/agentscope/prompts/memory_prompt.py` | Agent prompt 构建 | + +### Flutter + +| 文件 | 职责 | +|------|------| +| `apps/lib/features/settings/data/services/memory_service.dart` | Memory API 服务 | +| `apps/lib/features/settings/ui/screens/memory_screen.dart` | Memory 界面 | diff --git a/docs/protocols/models/users.md b/docs/protocols/models/users.md new file mode 100644 index 0000000..6290001 --- /dev/null +++ b/docs/protocols/models/users.md @@ -0,0 +1,119 @@ +# Users 协议 + +本文档定义 `/api/v1/users` 的用户管理协议。 + +Base URL: `/api/v1/users` + +--- + +## 端点 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/me` | 获取当前用户信息 | +| PATCH | `/me` | 更新当前用户信息 | +| POST | `/search` | 搜索用户 | +| GET | `/{user_id}` | 获取指定用户信息 | + +--- + +## UserContext 数据结构 + +```json +{ + "id": "uuid", + "username": "string", + "phone": "+14155552671 | null", + "avatar_url": "https://... | null", + "bio": "string | null", + "settings": { + "version": 1, + "preferences": { + "interface_language": "zh-CN", + "ai_language": "zh-CN", + "timezone": "Asia/Shanghai", + "country": "CN" + }, + "privacy": {}, + "notification": {} + } +} +``` + +字段说明: +- `id`: 用户唯一标识符 (UUID) +- `username`: 用户名,3-30 字符 +- `phone`: E.164 格式手机号,可为 null +- `avatar_url`: 头像 URL,可为 null +- `bio`: 个人简介,最大 200 字符,可为 null +- `settings`: 用户偏好设置 + +--- + +## 1) GET `/me` + +获取当前登录用户信息。 + +### Request + +无请求体。 + +### Response + +`UserContext` 对象。 + +--- + +## 2) PATCH `/me` + +更新当前用户信息(部分更新)。 + +### Request + +```json +{ + "username": "newname", + "avatar_url": "https://example.com/avatar.jpg", + "bio": "Hello world" +} +``` + +至少提供一个字段。 + +### Response + +更新后的 `UserContext` 对象。 + +--- + +## 3) POST `/search` + +按用户名或手机号搜索用户。 + +### Request + +```json +{ + "query": "john" +} +``` + +`query` 最小 1 字符,最大 100 字符。 + +### Response + +`UserContext` 对象数组。 + +--- + +## 4) GET `/{user_id}` + +获取指定用户信息。 + +### Path Parameters + +- `user_id`: 用户 UUID + +### Response + +`UserContext` 对象。