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 1d245d9..88e70bb 100644 --- a/apps/lib/features/chat/data/models/ag_ui_event.dart +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -308,7 +308,7 @@ class HistoryMessage { required this.role, required this.content, required this.timestamp, - this.url, + this.attachments = const [], this.uiSchema, }); @@ -317,7 +317,7 @@ class HistoryMessage { final String role; final String content; final DateTime timestamp; - final String? url; + final List attachments; final Map? uiSchema; factory HistoryMessage.fromJson(Map json) => HistoryMessage( @@ -327,11 +327,25 @@ class HistoryMessage { content: _asString(json['content']), timestamp: DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(), - url: json['url'] as String?, + attachments: _parseHistoryAttachments(json['attachments']), uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']), ); } +class HistoryAttachment { + const HistoryAttachment({required this.url, required this.mimeType}); + + final String url; + final String mimeType; + + factory HistoryAttachment.fromJson(Map json) { + return HistoryAttachment( + url: _asString(json['url']), + mimeType: _asString(json['mimeType']), + ); + } +} + String _asString(Object? value, {String fallback = ''}) { if (value is String) { return value; @@ -368,3 +382,17 @@ Map? _asMap(Object? value) { } return null; } + +List _parseHistoryAttachments(Object? value) { + if (value is! List) { + return const []; + } + return value + .whereType>() + .map(HistoryAttachment.fromJson) + .where( + (attachment) => + attachment.url.isNotEmpty && attachment.mimeType.isNotEmpty, + ) + .toList(); +} diff --git a/apps/lib/features/chat/presentation/bloc/agent_stage.dart b/apps/lib/features/chat/presentation/bloc/agent_stage.dart new file mode 100644 index 0000000..d3199d6 --- /dev/null +++ b/apps/lib/features/chat/presentation/bloc/agent_stage.dart @@ -0,0 +1,20 @@ +enum AgentStage { intent, execution } + +AgentStage? stageFromStepName(String value) { + switch (value) { + case 'router': + return AgentStage.intent; + case 'worker': + return AgentStage.execution; + default: + return null; + } +} + +String stageLabel(AgentStage? stage) { + return switch (stage) { + AgentStage.intent => '意图识别中', + AgentStage.execution => '任务执行中', + null => '任务处理中', + }; +} diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 1b5a317..6b4d964 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -7,8 +7,7 @@ import 'package:social_app/core/api/i_api_client.dart'; import '../../data/models/ag_ui_event.dart'; import '../../data/models/chat_list_item.dart'; import '../../data/services/ag_ui_service.dart'; - -enum AgentStage { intent, execution, report } +import 'agent_stage.dart'; class ChatState { final List items; @@ -93,6 +92,19 @@ class ChatBloc extends Cubit { final Map> _attachmentPreviewInflight = >{}; + /// Common state reset for run completion (success/error/cancel) + ChatState _resetRunState({String? error, String? currentMessageId}) { + return state.copyWith( + isSending: false, + isWaitingFirstToken: false, + isStreaming: false, + isCancelling: false, + currentMessageId: currentMessageId, + error: error, + currentStage: null, + ); + } + void _handleEvent(AgUiEvent event) { switch (event.type) { case AgUiEventType.runStarted: @@ -106,29 +118,10 @@ class ChatBloc extends Cubit { ), ); case AgUiEventType.runFinished: - emit( - state.copyWith( - isSending: false, - isWaitingFirstToken: false, - isStreaming: false, - isCancelling: false, - currentMessageId: null, - currentStage: null, - ), - ); + emit(_resetRunState()); case AgUiEventType.runError: final errorEvent = event as RunErrorEvent; - emit( - state.copyWith( - isSending: false, - isWaitingFirstToken: false, - isStreaming: false, - isCancelling: false, - currentMessageId: null, - error: errorEvent.message, - currentStage: null, - ), - ); + emit(_resetRunState(error: errorEvent.message)); case AgUiEventType.stepStarted: _handleStepStarted(event as StepStartedEvent); case AgUiEventType.stepFinished: @@ -151,57 +144,27 @@ class ChatBloc extends Cubit { } void _handleStepStarted(StepStartedEvent event) { - emit(state.copyWith(currentStage: _stageFromName(event.stepName))); + emit(state.copyWith(currentStage: stageFromStepName(event.stepName))); } void _handleStepFinished(StepFinishedEvent event) { - if (state.currentStage == _stageFromName(event.stepName)) { + if (state.currentStage == stageFromStepName(event.stepName)) { emit(state.copyWith(currentStage: null)); } } void _handleTextMessageEnd(TextMessageEndEvent event) { final timestamp = DateTime.now(); - final items = List.from(state.items); - - final messageIndex = items.indexWhere( - (item) => item.id == event.messageId && item is TextMessageItem, + final items = _updateOrAddMessage( + state.items, + event.messageId, + event.answer, + timestamp, ); - if (messageIndex >= 0) { - final existing = items[messageIndex] as TextMessageItem; - items[messageIndex] = existing.copyWith( - content: event.answer, - isStreaming: false, - ); - } else { - items.add( - TextMessageItem( - id: event.messageId, - content: event.answer, - timestamp: timestamp, - sender: MessageSender.ai, - isStreaming: false, - ), - ); - } - final uiSchema = event.uiSchema; if (uiSchema != null) { - final uiItemId = '${event.messageId}-ui'; - final existingUiIndex = items.indexWhere((item) => item.id == uiItemId); - final uiItem = ToolResultItem( - id: uiItemId, - callId: event.messageId, - uiSchema: uiSchema, - timestamp: timestamp, - sender: MessageSender.ai, - ); - if (existingUiIndex >= 0) { - items[existingUiIndex] = uiItem; - } else { - items.add(uiItem); - } + _upsertUiSchema(items, event.messageId, uiSchema, timestamp); } emit( @@ -214,6 +177,56 @@ class ChatBloc extends Cubit { ); } + List _updateOrAddMessage( + List items, + String messageId, + String content, + DateTime timestamp, + ) { + final result = List.from(items); + final index = result.indexWhere( + (item) => item.id == messageId && item is TextMessageItem, + ); + + if (index >= 0) { + final existing = result[index] as TextMessageItem; + result[index] = existing.copyWith(content: content, isStreaming: false); + } else { + result.add( + TextMessageItem( + id: messageId, + content: content, + timestamp: timestamp, + sender: MessageSender.ai, + isStreaming: false, + ), + ); + } + return result; + } + + void _upsertUiSchema( + List items, + String messageId, + Map uiSchema, + DateTime timestamp, + ) { + final uiItemId = '$messageId-ui'; + final existingIndex = items.indexWhere((item) => item.id == uiItemId); + final uiItem = ToolResultItem( + id: uiItemId, + callId: messageId, + uiSchema: uiSchema, + timestamp: timestamp, + sender: MessageSender.ai, + ); + if (existingIndex >= 0) { + items[existingIndex] = uiItem; + } else { + items.add(uiItem); + } + } + void _handleToolCallStart(ToolCallStartEvent event) { final items = List.from(state.items) ..add( @@ -299,10 +312,14 @@ class ChatBloc extends Cubit { final converted = []; for (final msg in messages) { final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai; - final attachments = >[]; - if (msg.url != null && msg.url!.isNotEmpty) { - attachments.add({'url': msg.url!, 'mimeType': 'image/*'}); - } + final attachments = msg.attachments + .map( + (attachment) => { + 'url': attachment.url, + 'mimeType': attachment.mimeType, + }, + ) + .toList(); if (msg.content.isNotEmpty || sender == MessageSender.user) { converted.add( @@ -500,16 +517,3 @@ class ChatBloc extends Cubit { emit(state.copyWith(error: null)); } } - -AgentStage? _stageFromName(String value) { - switch (value) { - case 'intent': - return AgentStage.intent; - case 'execution': - return AgentStage.execution; - case 'report': - return AgentStage.report; - default: - return null; - } -} diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index 71abaa8..ebec7b5 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -94,24 +94,28 @@ class UiSchemaRenderer { final status = _asString(node['status']); final style = switch (role) { 'title' => const TextStyle( - fontSize: 17, + fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.slate900, - height: 1.25, + height: 1.2, ), 'subtitle' => const TextStyle( - fontSize: 15, + fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.slate800, ), - 'caption' => const TextStyle(fontSize: 12, color: AppColors.slate500), + 'caption' => const TextStyle( + fontSize: 12, + color: AppColors.slate500, + height: 1.4, + ), 'code' => const TextStyle( fontSize: 12, color: AppColors.slate700, fontFamily: 'monospace', ), _ => const TextStyle( - fontSize: 14, + fontSize: 15, color: AppColors.slate700, height: 1.45, ), @@ -139,16 +143,17 @@ class UiSchemaRenderer { final bg = _statusBackground(status); return Container( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, + horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: _statusBorder(status)), ), child: Text( _asString(node['label']), - style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fg), ), ); } @@ -173,17 +178,20 @@ class UiSchemaRenderer { style: ElevatedButton.styleFrom( elevation: 0, padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, ), backgroundColor: style == 'primary' - ? AppColors.blue600 - : AppColors.homeComposerAccent, + ? AppColors.authPrimaryButton + : AppColors.surfaceInfoLight, foregroundColor: style == 'primary' ? AppColors.white : AppColors.slate700, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), + borderRadius: BorderRadius.circular(AppRadius.full), + side: style == 'primary' + ? BorderSide.none + : const BorderSide(color: AppColors.borderTertiary), ), ), child: Text( @@ -211,32 +219,43 @@ class UiSchemaRenderer { fallback: _asString(item['key']), ); final value = item['value']?.toString() ?? '-'; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppColors.slate500, + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppColors.slate500, + ), ), ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - flex: 5, - child: Text( - value, - style: const TextStyle( - fontSize: 13, - color: AppColors.slate800, - fontWeight: FontWeight.w500, + const SizedBox(width: AppSpacing.sm), + Expanded( + flex: 5, + child: Text( + value, + style: const TextStyle( + fontSize: 13, + color: AppColors.slate800, + fontWeight: FontWeight.w600, + ), ), ), - ), - ], + ], + ), ); }).toList(), AppSpacing.xs, @@ -259,7 +278,8 @@ class UiSchemaRenderer { return child; } final bg = switch (appearance) { - 'section' => AppColors.homeComposerInner, + 'section' => AppColors.surfaceSecondary, + 'card' => AppColors.white, _ => _statusBackground(status), }; final borderColor = switch (status) { @@ -270,16 +290,16 @@ class UiSchemaRenderer { }; return Container( width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.lg), + padding: const EdgeInsets.all(AppSpacing.xl), decoration: BoxDecoration( color: bg, - borderRadius: BorderRadius.circular(AppRadius.lg), + borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: borderColor), boxShadow: [ BoxShadow( - color: AppColors.blue100.withValues(alpha: 0.35), - blurRadius: 18, - offset: const Offset(0, 8), + color: AppColors.slate200.withValues(alpha: 0.6), + blurRadius: 20, + offset: const Offset(0, 10), ), ], ), @@ -325,7 +345,17 @@ class UiSchemaRenderer { 'warning' => AppColors.feedbackWarningSurface, 'error' => AppColors.feedbackErrorSurface, 'pending' => AppColors.feedbackInfoSurface, - _ => AppColors.homeConversationSurface, + _ => AppColors.surfaceSecondary, + }; + } + + static Color _statusBorder(String status) { + return switch (status) { + 'success' => AppColors.feedbackSuccessBorder, + 'warning' => AppColors.feedbackWarningBorder, + 'error' => AppColors.feedbackErrorBorder, + 'pending' => AppColors.feedbackInfoBorder, + _ => AppColors.borderTertiary, }; } diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index f67a82d..651c39b 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -10,6 +10,7 @@ import '../../../../core/api/api_exception.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../chat/data/models/chat_list_item.dart'; +import '../../../chat/presentation/bloc/agent_stage.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; @@ -49,6 +50,14 @@ const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient'); const _chatBgColor = AppColors.slate50; const _userBubbleColor = AppColors.blue50; +/// 录制状态颜色 +const _recordingCancelTopColor = AppColors.warningBackground; +const _recordingCancelBottomColor = AppColors.red400; +const _recordingCancelLabelColor = AppColors.red600; +const _recordingActiveTopColor = AppColors.blue50; +const _recordingActiveBottomColor = AppColors.blue400; +const _recordingActiveLabelColor = AppColors.white; + class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; @@ -258,12 +267,7 @@ class _HomeScreenState extends State } Widget _buildWaitingIndicator({required AgentStage? currentStage}) { - final label = switch (currentStage) { - AgentStage.intent => '意图识别中', - AgentStage.execution => '任务执行中', - AgentStage.report => '结果总结中', - null => '正在思考...', - }; + final label = stageLabel(currentStage); return Padding( padding: const EdgeInsets.fromLTRB( _defaultPadding, @@ -490,12 +494,12 @@ class _HomeScreenState extends State Widget _buildToolCallItem(ToolCallItem item) { final (statusText, statusColor, statusIcon) = switch (item.status) { ToolCallStatus.pending => ( - '准备中...', + '工具准备中', AppColors.slate500, LucideIcons.clock, ), ToolCallStatus.executing => ( - '执行中...', + '任务执行中', AppColors.blue600, LucideIcons.loader, ), @@ -512,27 +516,51 @@ class _HomeScreenState extends State }; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppColors.blue50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.slate300), + color: AppColors.surfaceInfoLight, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.borderTertiary), ), child: Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(statusIcon, size: 16, color: statusColor), - const SizedBox(width: AppSpacing.sm), - Text( - item.toolName, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate700, + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.borderTertiary), ), + child: Icon(statusIcon, size: 14, color: statusColor), ), const SizedBox(width: AppSpacing.sm), - Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.toolName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.slate800, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + statusText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), ], ), ); @@ -819,16 +847,17 @@ class _HomeScreenState extends State } Widget _buildRecordingGestureOverlay() { - final topColor = _isCancelGestureActive - ? AppColors.warningBackground - : AppColors.blue50; - final bottomColor = _isCancelGestureActive - ? AppColors.red400 - : AppColors.blue400; - final labelColor = _isCancelGestureActive - ? AppColors.red600 - : AppColors.white; - final label = _isCancelGestureActive ? '松手取消' : '松手发送,上移取消'; + final isCancel = _isCancelGestureActive; + final topColor = isCancel + ? _recordingCancelTopColor + : _recordingActiveTopColor; + final bottomColor = isCancel + ? _recordingCancelBottomColor + : _recordingActiveBottomColor; + final labelColor = isCancel + ? _recordingCancelLabelColor + : _recordingActiveLabelColor; + final label = isCancel ? '松手取消' : '松手发送,上移取消'; return IgnorePointer( child: Align( diff --git a/apps/lib/features/settings/ui/screens/account_screen.dart b/apps/lib/features/settings/ui/screens/account_screen.dart index 4be0391..f715d91 100644 --- a/apps/lib/features/settings/ui/screens/account_screen.dart +++ b/apps/lib/features/settings/ui/screens/account_screen.dart @@ -2,71 +2,104 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; import '../../../auth/presentation/bloc/auth_bloc.dart'; import '../../../auth/presentation/bloc/auth_event.dart'; import '../../../auth/presentation/bloc/auth_state.dart'; +import '../widgets/account_section_card.dart'; +import '../widgets/account_surface_scaffold.dart'; class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - children: [ - _buildHeader(context), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - _buildMenuCard(context), - const SizedBox(height: 24), - _buildLogoutButton(context), - ], - ), - ), - ), - ], - ), + return AccountSurfaceScaffold( + title: '我的账户', + subtitle: '管理资料信息与账户安全', + onBack: () => context.pop(), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildProfileHero(context), + const SizedBox(height: AppSpacing.lg), + _buildMenuCard(context), + const SizedBox(height: AppSpacing.lg), + _buildSecurityCard(context), + ], ), ); } - Widget _buildHeader(BuildContext context) { - return SizedBox( - height: 64, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - widgets.BackButton(), - const SizedBox(width: 12), - const Text( - '我的账户', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.slate900, + Widget _buildProfileHero(BuildContext context) { + final authState = context.watch().state; + final email = authState is AuthAuthenticated ? authState.user.email : ''; + final identity = email.isEmpty ? '当前登录账户' : email; + final badge = email.isEmpty ? 'A' : email.characters.first.toUpperCase(); + + return AccountSectionCard( + title: '账户概览', + description: '查看当前账户状态与基础身份信息', + backgroundColor: AppColors.surfaceInfoLight, + borderColor: AppColors.borderQuaternary, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.borderQuaternary), + ), + child: Center( + child: Text( + badge, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.blue600, + ), ), ), - ], - ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + identity, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + const Text( + '账户状态正常,可安全管理资料与密码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + ), + ), + ], ), ); } Widget _buildMenuCard(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderSecondary), - ), + return AccountSectionCard( + title: '账户信息', + description: '编辑公开资料和登录信息', child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildMenuItem( icon: Icons.edit, @@ -84,6 +117,52 @@ class AccountScreen extends StatelessWidget { ); } + Widget _buildSecurityCard(BuildContext context) { + return AccountSectionCard( + title: '安全与会话', + description: '若为公共设备,请及时退出登录', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.surfaceSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.borderSecondary), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.shield_outlined, + size: 16, + color: AppColors.slate500, + ), + SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + '退出后需要重新登录才能继续使用。', + style: TextStyle( + fontSize: 12, + color: AppColors.slate500, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + _buildLogoutButton(context), + ], + ), + ); + } + Widget _buildMenuItem({ required IconData icon, required String title, @@ -93,15 +172,17 @@ class AccountScreen extends StatelessWidget { onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 16), + height: AppSpacing.xxl, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(icon, size: 20, color: AppColors.slate500), - const SizedBox(width: 12), + const SizedBox(width: AppSpacing.md), Text( title, style: const TextStyle( @@ -126,8 +207,8 @@ class AccountScreen extends StatelessWidget { Widget _buildDivider() { return Container( height: 1, - margin: const EdgeInsets.symmetric(horizontal: 16), - color: const Color(0xFFEEF2F7), + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + color: AppColors.borderTertiary, ); } @@ -138,9 +219,9 @@ class AccountScreen extends StatelessWidget { width: double.infinity, height: 52, decoration: BoxDecoration( - color: const Color(0xFFFEE2E2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFFECACA)), + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.feedbackErrorBorder), ), child: const Center( child: Text( @@ -148,7 +229,7 @@ class AccountScreen extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: Color(0xFFDC2626), + color: AppColors.feedbackErrorText, ), ), ), @@ -179,7 +260,10 @@ class AccountScreen extends StatelessWidget { context.go('/'); } }, - child: const Text('退出', style: TextStyle(color: Color(0xFFDC2626))), + child: const Text( + '退出', + style: TextStyle(color: AppColors.feedbackErrorText), + ), ), ], ), diff --git a/apps/lib/features/settings/ui/screens/change_password_screen.dart b/apps/lib/features/settings/ui/screens/change_password_screen.dart index 87ad11d..0dc3787 100644 --- a/apps/lib/features/settings/ui/screens/change_password_screen.dart +++ b/apps/lib/features/settings/ui/screens/change_password_screen.dart @@ -5,14 +5,16 @@ import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/banner/app_banner.dart'; import '../../../../shared/widgets/fixed_length_code_input.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; import '../../../auth/presentation/bloc/auth_bloc.dart'; import '../../../auth/presentation/bloc/auth_state.dart'; import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart'; import '../../../../features/auth/data/auth_repository.dart'; +import '../widgets/account_section_card.dart'; +import '../widgets/account_surface_scaffold.dart'; class ChangePasswordScreen extends StatelessWidget { const ChangePasswordScreen({super.key}); @@ -39,19 +41,13 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { final _confirmPasswordController = TextEditingController(); bool _obscurePassword = true; bool _obscureConfirmPassword = true; - String _userEmail = ''; - @override - void initState() { - super.initState(); - _loadUserEmail(); - } - - void _loadUserEmail() { + String _resolveUserEmail() { final authState = context.read().state; if (authState is AuthAuthenticated) { - _userEmail = authState.user.email; + return authState.user.email; } + return ''; } @override @@ -63,8 +59,14 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { } Future _handleSubmit() async { + final email = _resolveUserEmail(); + if (email.isEmpty) { + Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning); + return; + } + final cubit = context.read(); - cubit.emailChanged(_userEmail); + cubit.emailChanged(email); cubit.codeChanged(_codeController.text); cubit.newPasswordChanged(_passwordController.text); cubit.confirmPasswordChanged(_confirmPasswordController.text); @@ -94,281 +96,307 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { Toast.show(context, state.errorMessage!, type: ToastType.error); } }, - child: Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - children: [ - SizedBox( - height: 64, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - const widgets.BackButton(), - const SizedBox(width: 12), - const Text( - '修改密码', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.slate900, - ), - ), - ], - ), - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildEmailDisplay(), - const SizedBox(height: 24), - _buildForm(), - ], - ), - ), - ), - ], - ), + child: AccountSurfaceScaffold( + title: '修改密码', + subtitle: '通过邮箱验证码安全更新你的登录密码', + onBack: () => context.pop(), + body: _buildForm(), + footer: BlocBuilder( + builder: (context, state) { + return _buildSubmitButton(state); + }, ), ), ); } - Widget _buildEmailDisplay() { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderSecondary), - ), - child: Row( - children: [ - const Icon(Icons.email_outlined, size: 20, color: AppColors.slate500), - const SizedBox(width: 12), - Text( - _userEmail, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: AppColors.slate900, - ), - ), - ], - ), - ); - } - Widget _buildForm() { return BlocBuilder( builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCodeInput(state), - const SizedBox(height: 16), - _buildPasswordInput(state.newPassword.displayError != null), - const SizedBox(height: 16), - _buildConfirmPasswordInput( + _buildEmailSection(state, _resolveUserEmail()), + const SizedBox(height: AppSpacing.lg), + _buildPasswordSection( + state, + state.newPassword.displayError != null, state.confirmPassword.displayError != null, ), - const SizedBox(height: 32), - _buildSubmitButton(state), ], ); }, ); } - Widget _buildCodeInput(ResetPasswordState state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '验证码', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate700, + Widget _buildEmailSection(ResetPasswordState state, String userEmail) { + return AccountSectionCard( + title: '第 1 步:验证邮箱', + description: '先向登录邮箱发送验证码,再进行密码设置', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: AppColors.surfaceInfoLight, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.borderTertiary), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.email_outlined, + size: 20, + color: AppColors.blue600, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + userEmail.isEmpty ? '未读取到登录邮箱' : userEmail, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate900, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: FixedLengthCodeInput( - controller: _codeController, - length: 6, - semanticLabel: '修改密码验证码输入框', - keyboardType: TextInputType.number, - allowedCharacters: const { - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - }, - onChanged: (value) { - context.read().codeChanged(value); - }, - ), + const SizedBox(height: AppSpacing.lg), + AppButton( + text: state.resendCountdown > 0 + ? '${state.resendCountdown} 秒后可重发' + : (state.codeSent ? '重新发送验证码' : '发送验证码'), + onPressed: + state.resendCountdown > 0 || + state.status == FormzSubmissionStatus.inProgress + ? null + : () { + if (userEmail.isEmpty) { + Toast.show( + context, + '未读取到登录邮箱,请重新登录后重试', + type: ToastType.warning, + ); + return; + } + if (state.codeSent) { + context.read().resendCode(); + } else { + context.read().emailChanged( + userEmail, + ); + context.read().sendCode(); + } + }, + isOutlined: state.codeSent, + ), + ], + ), + ); + } + + Widget _buildPasswordSection( + ResetPasswordState state, + bool passwordHasError, + bool confirmHasError, + ) { + return AccountSectionCard( + title: '第 2 步:输入验证码并设置新密码', + description: '验证码有效后,确认新密码即可完成修改', + backgroundColor: state.codeSent + ? AppColors.white + : AppColors.surfaceTertiary, + borderColor: state.codeSent + ? AppColors.borderSecondary + : AppColors.borderTertiary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!state.codeSent) + const AppBanner( + title: '请先发送验证码', + message: '完成邮箱验证后,可继续设置新密码。', + type: ToastType.info, ), - const SizedBox(width: 12), - SizedBox( - height: 48, - child: TextButton( - onPressed: - state.resendCountdown > 0 || - state.status == FormzSubmissionStatus.inProgress - ? null - : () { - if (state.codeSent) { - context.read().resendCode(); - } else { - context.read().emailChanged( - _userEmail, - ); - context.read().sendCode(); - } - }, - style: TextButton.styleFrom( - backgroundColor: state.codeSent - ? AppColors.background - : AppColors.primary, - foregroundColor: state.codeSent - ? AppColors.primary - : AppColors.primaryForeground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - ), - child: Text( - state.resendCountdown > 0 - ? '${state.resendCountdown}秒' - : (state.codeSent ? '重新发送' : '发送验证码'), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), + if (state.codeSent) + AppBanner( + title: '验证码已发送', + message: state.resendCountdown > 0 + ? '如未收到,可在 ${state.resendCountdown} 秒后重新发送。' + : '若未收到邮件,可重新发送验证码。', + type: ToastType.info, ), - ], - ), - ], + const SizedBox(height: AppSpacing.lg), + const Text( + '验证码', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.slate700, + ), + ), + const SizedBox(height: AppSpacing.sm), + FixedLengthCodeInput( + controller: _codeController, + length: 6, + semanticLabel: '修改密码验证码输入框', + keyboardType: TextInputType.number, + allowedCharacters: const { + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + }, + onChanged: (value) { + context.read().codeChanged(value); + }, + ), + const SizedBox(height: AppSpacing.lg), + _buildPasswordInput(passwordHasError), + const SizedBox(height: AppSpacing.lg), + _buildConfirmPasswordInput(confirmHasError), + ], + ), ); } Widget _buildPasswordInput(bool hasError) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '新密码', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate700, - ), - ), - const SizedBox(height: 8), - TextField( - controller: _passwordController, - obscureText: _obscurePassword, - onChanged: (value) { - context.read().newPasswordChanged(value); - }, - decoration: InputDecoration( - hintText: '请输入新密码(至少 6 位)', - errorText: hasError ? ' ' : null, - filled: true, - fillColor: AppColors.white, - suffixIcon: IconButton( - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, - size: 20, - color: AppColors.slate400, - ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - ), - ), - ), - ], + return _buildPasswordField( + label: '新密码', + controller: _passwordController, + hintText: '请输入新密码(至少 6 位)', + hasError: hasError, + isObscured: _obscurePassword, + onToggleVisibility: () => + setState(() => _obscurePassword = !_obscurePassword), + onChanged: (value) => + context.read().newPasswordChanged(value), ); } Widget _buildConfirmPasswordInput(bool hasError) { + return _buildPasswordField( + label: '确认密码', + controller: _confirmPasswordController, + hintText: '请再次输入新密码', + hasError: hasError, + isObscured: _obscureConfirmPassword, + onToggleVisibility: () => + setState(() => _obscureConfirmPassword = !_obscureConfirmPassword), + onChanged: (value) => + context.read().confirmPasswordChanged(value), + ); + } + + Widget _buildPasswordField({ + required String label, + required TextEditingController controller, + required String hintText, + required bool hasError, + required bool isObscured, + required VoidCallback onToggleVisibility, + required ValueChanged onChanged, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '确认密码', - style: TextStyle( + Text( + label, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.slate700, ), ), - const SizedBox(height: 8), + const SizedBox(height: AppSpacing.sm), TextField( - controller: _confirmPasswordController, - obscureText: _obscureConfirmPassword, - onChanged: (value) { - context.read().confirmPasswordChanged(value); - }, + controller: controller, + obscureText: isObscured, + onChanged: onChanged, decoration: InputDecoration( - hintText: '请再次输入新密码', + hintText: hintText, errorText: hasError ? ' ' : null, filled: true, - fillColor: AppColors.white, + fillColor: AppColors.surfaceSecondary, + hintStyle: const TextStyle(color: AppColors.slate400), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.lg, + ), suffixIcon: IconButton( icon: Icon( - _obscureConfirmPassword - ? Icons.visibility_off - : Icons.visibility, + isObscured ? Icons.visibility_off : Icons.visibility, size: 20, color: AppColors.slate400, ), - onPressed: () { - setState(() { - _obscureConfirmPassword = !_obscureConfirmPassword; - }); - }, + onPressed: onToggleVisibility, ), + border: _inputBorder, + enabledBorder: _enabledBorder, + focusedBorder: _focusedBorder, ), ), ], ); } + static final _inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide.none, + ); + + static final _enabledBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: const BorderSide(color: AppColors.borderTertiary), + ); + + static final _focusedBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: const BorderSide(color: AppColors.blue500), + ); + Widget _buildSubmitButton(ResetPasswordState state) { final isLoading = state.status == FormzSubmissionStatus.inProgress; - final isDisabled = isLoading || !state.codeSent; + final isDisabled = isLoading || !state.canSubmit; - return SizedBox( - width: double.infinity, - height: 52, - child: AppButton( - text: '确认修改', - onPressed: isDisabled ? null : _handleSubmit, - isLoading: isLoading, - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!state.codeSent) + const Text( + '完成验证码验证后可提交密码修改', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + if (!state.codeSent) const SizedBox(height: AppSpacing.sm), + SizedBox( + width: double.infinity, + height: 52, + child: AppButton( + text: '确认修改', + onPressed: isDisabled ? null : _handleSubmit, + isLoading: isLoading, + ), + ), + ], ); } } diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index 858aa5b..328ea37 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -4,11 +4,12 @@ import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; -import '../../../../shared/widgets/page_header.dart' as widgets; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../users/data/models/user_response.dart'; import '../../../users/data/users_api.dart'; +import '../widgets/account_section_card.dart'; +import '../widgets/account_surface_scaffold.dart'; class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -118,193 +119,187 @@ class _EditProfileScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surfaceSecondary, - body: SafeArea( - child: Column( - children: [ - _buildHeader(), - Expanded( - child: _isLoading - ? const Center(child: AppLoadingIndicator(size: 22)) - : SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - _buildAvatarSection(), - const SizedBox(height: 24), - _buildFormSection(), - const SizedBox(height: 32), - SizedBox( - width: double.infinity, - height: 52, - child: AppButton( - text: '保存修改', - onPressed: _hasChanges && !_isSaving - ? _saveProfile - : null, - isLoading: _isSaving, - ), - ), - ], - ), - ), + return AccountSurfaceScaffold( + title: '编辑资料', + subtitle: '完善公开信息,让好友更容易认识你', + onBack: () => context.pop(), + body: _isLoading + ? const Center( + child: AppLoadingIndicator(variant: AppLoadingVariant.surface), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildProfileSummarySection(), + const SizedBox(height: AppSpacing.lg), + _buildBasicInfoSection(), + const SizedBox(height: AppSpacing.lg), + _buildBioSection(), + ], ), - ], + footer: SizedBox( + width: double.infinity, + height: 52, + child: AppButton( + text: '保存修改', + onPressed: _hasChanges && !_isSaving ? _saveProfile : null, + isLoading: _isSaving, ), ), ); } - Widget _buildHeader() { - return SizedBox( - height: 64, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - widgets.BackButton(onPressed: () => context.pop()), - const SizedBox(width: 12), - const Text( - '编辑资料', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.slate900, - ), - ), - ], - ), - ), - ); - } + Widget _buildProfileSummarySection() { + final username = _user?.username ?? '未设置用户名'; + final email = _user?.email; - Widget _buildAvatarSection() { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderSecondary), - ), + return AccountSectionCard( + title: '资料概览', + description: '展示你的公开身份信息', + backgroundColor: AppColors.white, + borderColor: AppColors.borderSecondary, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFEAF1FF), Color(0xFFF8FBFF)], + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue100, AppColors.surfaceInfoLight], + ), + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.borderQuaternary), + ), + child: const Icon( + Icons.person, + size: 28, + color: AppColors.blue600, + ), ), - borderRadius: BorderRadius.circular(36), - border: Border.all(color: const Color(0xFFD9E5FA)), - ), - child: const Icon(Icons.person, size: 36, color: AppColors.blue500), - ), - const SizedBox(height: 12), - Text( - '点击更换头像', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + username, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Text( + email ?? '邮箱未绑定', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], ), ], ), ); } - Widget _buildFormSection() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.borderSecondary), - ), + Widget _buildBasicInfoSection() { + return AccountSectionCard( + title: '基础信息', + description: '用户名会在个人资料和社交场景中展示', + backgroundColor: AppColors.white, + borderColor: AppColors.borderSecondary, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '用户名', style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + fontSize: 13, + fontWeight: FontWeight.w700, color: AppColors.slate700, ), ), - const SizedBox(height: 8), + const SizedBox(height: AppSpacing.sm), TextField( controller: _usernameController, onChanged: (_) => _onFieldChanged(), - decoration: InputDecoration( - hintText: '请输入用户名', - filled: true, - fillColor: AppColors.surfaceSecondary, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: AppColors.blue500, - width: 1, - ), - ), - ), - ), - const SizedBox(height: 20), - const Text( - '个人简介', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.slate700, - ), - ), - const SizedBox(height: 8), - TextField( - controller: _bioController, - onChanged: (_) => _onFieldChanged(), - maxLines: 4, - maxLength: 200, - decoration: InputDecoration( - hintText: '介绍一下自己吧', - filled: true, - fillColor: AppColors.surfaceSecondary, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: AppColors.blue500, - width: 1, - ), - ), - ), + style: const TextStyle(fontSize: 15, color: AppColors.slate900), + decoration: _buildInputDecoration('请输入用户名'), ), ], ), ); } + + Widget _buildBioSection() { + return AccountSectionCard( + title: '个人简介', + description: '一句话介绍自己,帮助他人快速了解你', + backgroundColor: AppColors.white, + borderColor: AppColors.borderSecondary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '简介内容', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.slate700, + ), + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _bioController, + onChanged: (_) => _onFieldChanged(), + maxLines: 4, + maxLength: 200, + style: const TextStyle(fontSize: 15, color: AppColors.slate900), + decoration: _buildInputDecoration( + '介绍一下自己吧', + ).copyWith(contentPadding: const EdgeInsets.all(AppSpacing.lg)), + ), + ], + ), + ); + } + + InputDecoration _buildInputDecoration(String hintText) { + return InputDecoration( + hintText: hintText, + hintStyle: const TextStyle(fontSize: 14, color: AppColors.slate400), + filled: true, + fillColor: AppColors.surfaceSecondary, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.lg, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: const BorderSide(color: AppColors.borderTertiary), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: const BorderSide(color: AppColors.blue500), + ), + ); + } } diff --git a/apps/lib/features/settings/ui/widgets/account_section_card.dart b/apps/lib/features/settings/ui/widgets/account_section_card.dart new file mode 100644 index 0000000..0224799 --- /dev/null +++ b/apps/lib/features/settings/ui/widgets/account_section_card.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/design_tokens.dart'; + +class AccountSectionCard extends StatelessWidget { + const AccountSectionCard({ + super.key, + this.title, + this.description, + required this.child, + this.backgroundColor = AppColors.white, + this.borderColor = AppColors.borderSecondary, + }); + + final String? title; + final String? description; + final Widget child; + final Color backgroundColor; + final Color borderColor; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Text( + title!, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + ], + if (description != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + description!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + if (title != null || description != null) + const SizedBox(height: AppSpacing.lg), + child, + ], + ), + ); + } +} diff --git a/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart b/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart new file mode 100644 index 0000000..c9d571c --- /dev/null +++ b/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/page_header.dart' as widgets; + +class AccountSurfaceScaffold extends StatelessWidget { + const AccountSurfaceScaffold({ + super.key, + required this.title, + required this.subtitle, + required this.body, + this.footer, + this.onBack, + }); + + final String title; + final String subtitle; + final Widget body; + final Widget? footer; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.surfaceSecondary, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + widgets.PageHeader(leading: widgets.BackButton(onPressed: onBack)), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.none, + AppSpacing.xl, + AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + subtitle, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.sm, + AppSpacing.xl, + AppSpacing.xl, + ), + child: body, + ), + ), + if (footer != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.none, + AppSpacing.xl, + AppSpacing.xl, + ), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceInfoLight, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderTertiary), + ), + child: footer, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/test/features/chat/ag_ui_event_test.dart b/apps/test/features/chat/ag_ui_event_test.dart index fdf23fa..9fe27d5 100644 --- a/apps/test/features/chat/ag_ui_event_test.dart +++ b/apps/test/features/chat/ag_ui_event_test.dart @@ -87,5 +87,32 @@ void main() { expect(snapshot.messages, hasLength(1)); expect(snapshot.messages.first.uiSchema, isNotNull); }); + + test('parses history user attachments list', () { + final snapshot = HistorySnapshot.fromJson({ + 'scope': 'history_day', + 'threadId': 'thread_1', + 'day': '2026-03-16', + 'hasMore': false, + 'messages': [ + { + 'id': 'm1', + 'seq': 1, + 'role': 'user', + 'content': '请看图', + 'attachments': [ + {'url': 'https://signed.example/a.png', 'mimeType': 'image/png'}, + {'url': 'https://signed.example/b.jpg', 'mimeType': 'image/jpeg'}, + ], + 'timestamp': '2026-03-16T10:00:00Z', + }, + ], + }); + + final userMessage = snapshot.messages.first; + expect(userMessage.attachments, hasLength(2)); + expect(userMessage.attachments.first.url, 'https://signed.example/a.png'); + expect(userMessage.attachments.last.mimeType, 'image/jpeg'); + }); }); } diff --git a/apps/test/features/chat/presentation/agent_stage_mapping_test.dart b/apps/test/features/chat/presentation/agent_stage_mapping_test.dart new file mode 100644 index 0000000..a783a87 --- /dev/null +++ b/apps/test/features/chat/presentation/agent_stage_mapping_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart'; + +void main() { + group('agent stage mapping', () { + test('maps protocol step router to intent stage label', () { + final stage = stageFromStepName('router'); + + expect(stage, AgentStage.intent); + expect(stageLabel(stage), '意图识别中'); + }); + + test('maps protocol step worker to execution stage label', () { + final stage = stageFromStepName('worker'); + + expect(stage, AgentStage.execution); + expect(stageLabel(stage), '任务执行中'); + }); + + test('uses processing label when step is unknown', () { + final stage = stageFromStepName('unexpected'); + + expect(stage, isNull); + expect(stageLabel(stage), '任务处理中'); + }); + }); +} diff --git a/apps/test/features/settings/ui/screens/change_password_screen_test.dart b/apps/test/features/settings/ui/screens/change_password_screen_test.dart new file mode 100644 index 0000000..f7622ff --- /dev/null +++ b/apps/test/features/settings/ui/screens/change_password_screen_test.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; +import 'package:social_app/features/settings/ui/screens/change_password_screen.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late MockAuthRepository mockAuthRepository; + late AuthBloc authBloc; + + setUp(() async { + mockAuthRepository = MockAuthRepository(); + await sl.reset(); + sl.registerSingleton(mockAuthRepository); + + authBloc = AuthBloc(mockAuthRepository); + authBloc.add( + const AuthLoggedIn( + user: AuthUser(id: 'user-1', email: 'tester@example.com'), + ), + ); + }); + + tearDown(() async { + await authBloc.close(); + await sl.reset(); + }); + + Future pumpScreen(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: authBloc, + child: const MaterialApp(home: ChangePasswordScreen()), + ), + ); + await tester.pump(); + } + + testWidgets('确认修改按钮在验证码发送前不可点击', (tester) async { + when( + () => mockAuthRepository.requestPasswordReset(any()), + ).thenAnswer((_) async {}); + + await pumpScreen(tester); + + final confirmButton = tester.widget( + find.widgetWithText(ElevatedButton, '确认修改'), + ); + expect(confirmButton.onPressed, isNull); + expect(find.text('完成验证码验证后可提交密码修改'), findsOneWidget); + }); + + testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async { + final completer = Completer(); + when( + () => mockAuthRepository.requestPasswordReset(any()), + ).thenAnswer((_) => completer.future); + + await pumpScreen(tester); + + await tester.tap(find.text('发送验证码')); + await tester.pump(); + + expect(find.text('60 秒后可重发'), findsOneWidget); + + await tester.tap(find.text('60 秒后可重发')); + await tester.pump(); + + verify( + () => mockAuthRepository.requestPasswordReset('tester@example.com'), + ).called(1); + + completer.complete(); + }); +} diff --git a/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart b/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart new file mode 100644 index 0000000..fb8ad6b --- /dev/null +++ b/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/settings/ui/widgets/account_section_card.dart'; +import 'package:social_app/features/settings/ui/widgets/account_surface_scaffold.dart'; + +void main() { + testWidgets('AccountSectionCard renders title and description', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AccountSectionCard( + title: '基础信息', + description: '请填写公开展示资料', + child: Text('内容区'), + ), + ), + ), + ); + + expect(find.text('基础信息'), findsOneWidget); + expect(find.text('请填写公开展示资料'), findsOneWidget); + expect(find.text('内容区'), findsOneWidget); + }); + + testWidgets('AccountSurfaceScaffold renders header and footer', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: AccountSurfaceScaffold( + title: '编辑资料', + subtitle: '管理账户公开信息', + body: const Text('主体内容'), + footer: const Text('底部操作区'), + onBack: () {}, + ), + ), + ); + + expect(find.text('编辑资料'), findsOneWidget); + expect(find.text('管理账户公开信息'), findsOneWidget); + expect(find.text('主体内容'), findsOneWidget); + expect(find.text('底部操作区'), findsOneWidget); + }); +} diff --git a/backend/src/core/agentscope/prompts/agent_prompt.py b/backend/src/core/agentscope/prompts/agent_prompt.py index cdfe6cc..453c129 100644 --- a/backend/src/core/agentscope/prompts/agent_prompt.py +++ b/backend/src/core/agentscope/prompts/agent_prompt.py @@ -74,12 +74,34 @@ def _router_role_rules() -> list[str]: def _worker_role_rules() -> list[str]: return [ "Worker only: execute routed objective without changing router intent.", + "Treat router output as objective/constraints contract, not as a fully-materialized tool-args payload.", + "Infer deterministic required tool arguments from contract fields, tool schema, and runtime context.", + "Ask minimal clarification only when required arguments cannot be inferred safely.", "Ground every claim in available evidence and tool results; never fabricate execution state.", "Keep status/result_type/answer/key_points/suggested_actions/error internally consistent.", - "On partial/failed execution, return concise actionable error context.", ] +def build_worker_contract_prompt(*, router_output: RouterAgentOutput) -> str: + contract_json = json.dumps( + router_output.model_dump(mode="json", exclude_none=True), + ensure_ascii=False, + separators=(",", ":"), + ) + return "\n".join( + [ + "[Worker Contract]", + "- Keep routed objective unchanged.", + "- Use normalized_task_input as objective text.", + "- Use multimodal_summary/key_entities/constraints as execution evidence.", + "- Infer deterministic missing required tool args from evidence + tool schema.", + "- Ask clarification only when safe inference is impossible.", + "[RouterAgentOutput]", + contract_json, + ] + ) + + def build_agent_prompt(*, agent_type: AgentType) -> str: lines = [ "[Agent Identity]", diff --git a/backend/src/core/agentscope/runtime/json_react_agent.py b/backend/src/core/agentscope/runtime/json_react_agent.py index ea50971..1b48fc3 100644 --- a/backend/src/core/agentscope/runtime/json_react_agent.py +++ b/backend/src/core/agentscope/runtime/json_react_agent.py @@ -1,13 +1,12 @@ from __future__ import annotations -import json from typing import Any from agentscope.agent import ReActAgent from agentscope.message import Msg -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel -from core.agentscope.runtime.utils import extract_text_content, parse_json_dict +from core.agentscope.utils import finalize_json_response class JsonReActAgent(ReActAgent): @@ -47,77 +46,14 @@ class JsonReActAgent(ReActAgent): *, output_model: type[BaseModel], ) -> dict[str, Any]: - schema_json = json.dumps( - output_model.model_json_schema(), - ensure_ascii=True, - separators=(",", ":"), - ) - last_error = "" - - for attempt in range(1, self._finalize_retries + 2): - prompt = await self.formatter.format( - msgs=[ - Msg("system", self.sys_prompt, "system"), - *await self.memory.get_memory(), - Msg( - "user", - self._build_finalize_instruction( - schema_json=schema_json, - validation_error=last_error, - attempt=attempt, - ), - "user", - ), - ], - ) - - original_stream = self.model.stream - self.model.stream = False - try: - response = await self.model( - prompt, - tool_choice="none", - response_format={"type": "json_object"}, - ) - finally: - self.model.stream = original_stream - - raw_text = extract_text_content(getattr(response, "content", [])) - payload = parse_json_dict(raw_text) - if payload is None: - last_error = "Model output is not a valid JSON object." - continue - - try: - validated = output_model.model_validate(payload) - return validated.model_dump(mode="json", exclude_none=True) - except ValidationError as exc: - last_error = str(exc) - - raise RuntimeError( - f"failed to finalize structured output for {output_model.__name__}: {last_error}" - ) - - @staticmethod - def _build_finalize_instruction( - *, - schema_json: str, - validation_error: str, - attempt: int, - ) -> str: - error_part = ( - "" - if not validation_error - else ( - "\n\n[Validation Error From Previous Attempt]\n" - f"{validation_error}\n" - "Fix all missing/invalid fields and regenerate." - ) - ) - return ( - "Return JSON only. Do not output markdown, prose, or code fences. " - "Follow this JSON Schema exactly and include all required fields. " - "Do not call tools.\n\n" - f"[Schema]\n{schema_json}\n\n" - f"[Attempt]\n{attempt}{error_part}" + _, payload = await finalize_json_response( + model=self.model, + formatter=self.formatter, + base_messages=[ + Msg("system", self.sys_prompt, "system"), + *await self.memory.get_memory(), + ], + output_model=output_model, + retries=self._finalize_retries, ) + return payload diff --git a/backend/src/core/agentscope/runtime/model_tracking.py b/backend/src/core/agentscope/runtime/model_tracking.py new file mode 100644 index 0000000..3cc1c19 --- /dev/null +++ b/backend/src/core/agentscope/runtime/model_tracking.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any + +from agentscope.model import OpenAIChatModel + +from core.logging import get_logger + +logger = get_logger("core.agentscope.runtime.runner") + + +class TrackingChatModel: + def __init__(self, inner: OpenAIChatModel) -> None: + self._inner = inner + self._total_input_tokens = 0 + self._total_output_tokens = 0 + self._total_latency_ms = 0 + self._cached_prompt_tokens = 0 + + @property + def stream(self) -> bool: + return self._inner.stream + + @stream.setter + def stream(self, value: bool) -> None: + self._inner.stream = value + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + async def __call__(self, *args: Any, **kwargs: Any) -> Any: + self._log_model_call(kwargs) + response = await self._inner(*args, **kwargs) + if isinstance(response, AsyncGenerator): + return self._track_stream(response) + self._record_usage(getattr(response, "usage", None)) + return response + + def usage_summary(self) -> dict[str, int]: + return { + "input_tokens": self._total_input_tokens, + "output_tokens": self._total_output_tokens, + "latency_ms": self._total_latency_ms, + "cached_prompt_tokens": self._cached_prompt_tokens, + } + + def _log_model_call(self, kwargs: dict[str, Any]) -> None: + tools = kwargs.get("tools") + tool_names, generate_response_schema = self._extract_tool_debug_info(tools) + logger.info( + "model_call_debug", + tool_choice=kwargs.get("tool_choice"), + tool_count=len(tool_names), + tool_names=tool_names, + generate_response_schema=generate_response_schema, + ) + + @staticmethod + def _extract_tool_debug_info( + tools: Any, + ) -> tuple[list[str], dict[str, Any] | None]: + tool_names: list[str] = [] + generate_response_schema: dict[str, Any] | None = None + if not isinstance(tools, list): + return tool_names, generate_response_schema + + for tool in tools: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if not isinstance(function, dict): + continue + name = function.get("name") + if not isinstance(name, str): + continue + tool_names.append(name) + if name != "generate_response": + continue + parameters = function.get("parameters") + if not isinstance(parameters, dict): + continue + props = parameters.get("properties", {}) + generate_response_schema = { + "required": parameters.get("required"), + "properties": list(props.keys()) if isinstance(props, dict) else [], + } + return tool_names, generate_response_schema + + async def _track_stream( + self, response: AsyncGenerator[Any, None] + ) -> AsyncGenerator[Any, None]: + latest_usage = None + async for chunk in response: + usage = getattr(chunk, "usage", None) + if usage is not None: + latest_usage = usage + yield chunk + self._record_usage(latest_usage) + + def _record_usage(self, usage: Any) -> None: + if usage is None: + return + self._total_input_tokens += max(int(getattr(usage, "input_tokens", 0) or 0), 0) + self._total_output_tokens += max( + int(getattr(usage, "output_tokens", 0) or 0), 0 + ) + self._total_latency_ms += max( + int(round(float(getattr(usage, "time", 0) or 0) * 1000)), 0 + ) + metadata = getattr(usage, "metadata", None) + if metadata is None: + return + self._cached_prompt_tokens += max(self._extract_cached_tokens(metadata), 0) + + @staticmethod + def _extract_cached_tokens(metadata: Any) -> int: + if isinstance(metadata, dict): + prompt_details = metadata.get("prompt_tokens_details") + if isinstance(prompt_details, dict): + return int(prompt_details.get("cached_tokens", 0) or 0) + return 0 + + prompt_details = getattr(metadata, "prompt_tokens_details", None) + return int(getattr(prompt_details, "cached_tokens", 0) or 0) diff --git a/backend/src/core/agentscope/runtime/router_persistence.py b/backend/src/core/agentscope/runtime/router_persistence.py new file mode 100644 index 0000000..4c477df --- /dev/null +++ b/backend/src/core/agentscope/runtime/router_persistence.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from uuid import UUID, uuid4 + +from core.agentscope.events.persistence import MessageRepository, SessionRepository +from models.agent_chat_message import AgentChatMessageRole +from models.agent_chat_session import AgentChatSessionStatus +from schemas.agent.runtime_models import RouterAgentOutput +from schemas.agent.system_agent import AgentType +from schemas.messages.chat_message import AgentChatMessage, AgentChatMessageMetadata +from sqlalchemy.ext.asyncio import AsyncSession + + +def _to_int(value: object) -> int: + if value is None: + return 0 + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, Decimal): + return int(value) + if isinstance(value, float): + return int(value) + if isinstance(value, str): + text = value.strip() + if not text: + return 0 + try: + return int(text) + except ValueError: + return int(float(text)) + return 0 + + +async def persist_router_message( + *, + session: AsyncSession, + thread_id: str, + run_id: str, + model_code: str, + router_output: RouterAgentOutput, + response_metadata: dict[str, object], +) -> None: + session_id = UUID(thread_id) + message_repo = MessageRepository(session) + session_repo = SessionRepository(session) + locked_session = await session_repo.lock_session_for_update(session_id=session_id) + if locked_session is None: + raise RuntimeError("chat session not found for router persistence") + + seq = _to_int(getattr(locked_session, "message_count", 0)) + 1 + metadata = AgentChatMessageMetadata( + run_id=run_id, + agent_type=AgentType.ROUTER, + router_agent_output=router_output, + ) + message_payload = AgentChatMessage( + id=uuid4(), + seq=seq, + role=AgentChatMessageRole.ASSISTANT.value, + content="", + model_code=model_code, + tool_name=None, + input_tokens=_to_int(response_metadata.get("inputTokens", 0)), + output_tokens=_to_int(response_metadata.get("outputTokens", 0)), + cost=Decimal(str(response_metadata.get("cost", 0) or 0)), + latency_ms=_to_int(response_metadata.get("latencyMs", 0)), + metadata=metadata, + timestamp=datetime.now(timezone.utc), + ) + + await message_repo.append_message( + session_id=session_id, + seq=message_payload.seq, + role=AgentChatMessageRole.ASSISTANT, + content=message_payload.content, + model_code=message_payload.model_code, + tool_name=message_payload.tool_name, + metadata=metadata.model_dump(mode="json", exclude_none=True), + input_tokens=message_payload.input_tokens, + output_tokens=message_payload.output_tokens, + cost=message_payload.cost, + latency_ms=message_payload.latency_ms, + ) + await session_repo.update_runtime_state( + chat_session=locked_session, + status=AgentChatSessionStatus.RUNNING, + state_snapshot=locked_session.state_snapshot or {}, + message_delta=1, + token_delta=message_payload.input_tokens + message_payload.output_tokens, + cost_delta=message_payload.cost, + ) + await session.flush() diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index f70cfa9..4c7be2d 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -1,30 +1,28 @@ from __future__ import annotations -import json -from collections.abc import AsyncGenerator from dataclasses import dataclass from datetime import datetime, timezone -from decimal import Decimal from typing import TYPE_CHECKING, Any -from uuid import UUID, uuid4 +from uuid import UUID from ag_ui.core.types import RunAgentInput from agentscope.formatter import OpenAIChatFormatter from agentscope.memory import InMemoryMemory from agentscope.message import Msg from agentscope.model import OpenAIChatModel -from core.agentscope.events.persistence import MessageRepository, SessionRepository -from core.agentscope.runtime.json_react_agent import JsonReActAgent +from core.agentscope.prompts.agent_prompt import build_worker_contract_prompt from core.agentscope.prompts.system_prompt import build_system_prompt -from core.agentscope.tools.toolkit import build_stage_toolkit, build_toolkit -from core.agentscope.runtime.utils import ( - normalize_tool_name, - parse_tool_agent_output, +from core.agentscope.runtime.json_react_agent import JsonReActAgent +from core.agentscope.runtime.model_tracking import TrackingChatModel +from core.agentscope.runtime.router_persistence import persist_router_message +from core.agentscope.runtime.stage_emitter import PipelineStageEmitter +from core.agentscope.tools.toolkit import build_stage_toolkit +from core.agentscope.utils import ( + finalize_json_response, + patch_agentscope_json_repair_compat, ) from core.db.session import AsyncSessionLocal from core.logging import get_logger -from models.agent_chat_message import AgentChatMessageRole -from models.agent_chat_session import AgentChatSessionStatus from models.llm import Llm from models.system_agents import SystemAgents from schemas.agent.runtime_models import ( @@ -33,7 +31,6 @@ from schemas.agent.runtime_models import ( resolve_worker_output_model, ) from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig -from schemas.messages.chat_message import AgentChatMessage, AgentChatMessageMetadata from schemas.user import UserContext from services.litellm.service import LiteLLMService from sqlalchemy import select @@ -59,246 +56,9 @@ class StageExecutionResult: response_metadata: dict[str, Any] -class _TrackingChatModel: - def __init__(self, inner: OpenAIChatModel) -> None: - self._inner = inner - self._total_input_tokens = 0 - self._total_output_tokens = 0 - self._total_latency_ms = 0 - self._cached_prompt_tokens = 0 - - @property - def stream(self) -> bool: - return self._inner.stream - - @stream.setter - def stream(self, value: bool) -> None: - self._inner.stream = value - - def __getattr__(self, name: str) -> Any: - return getattr(self._inner, name) - - async def __call__(self, *args: Any, **kwargs: Any) -> Any: - tools = kwargs.get("tools") - tool_names: list[str] = [] - generate_response_schema: dict[str, Any] | None = None - if isinstance(tools, list): - for tool in tools: - if not isinstance(tool, dict): - continue - function = tool.get("function") - if isinstance(function, dict): - name = function.get("name") - if isinstance(name, str): - tool_names.append(name) - if name == "generate_response": - parameters = function.get("parameters") - if isinstance(parameters, dict): - generate_response_schema = { - "required": parameters.get("required"), - "properties": list( - ( - parameters.get("properties", {}) - if isinstance( - parameters.get("properties", {}), dict - ) - else {} - ).keys() - ), - } - logger.info( - "model_call_debug", - tool_choice=kwargs.get("tool_choice"), - tool_count=len(tool_names), - tool_names=tool_names, - generate_response_schema=generate_response_schema, - ) - response = await self._inner(*args, **kwargs) - if isinstance(response, AsyncGenerator): - return self._track_stream(response) - self._record_usage(getattr(response, "usage", None)) - return response - - async def _track_stream( - self, response: AsyncGenerator[Any, None] - ) -> AsyncGenerator[Any, None]: - latest_usage = None - async for chunk in response: - usage = getattr(chunk, "usage", None) - if usage is not None: - latest_usage = usage - yield chunk - self._record_usage(latest_usage) - - def _record_usage(self, usage: Any) -> None: - if usage is None: - return - self._total_input_tokens += max(int(getattr(usage, "input_tokens", 0) or 0), 0) - self._total_output_tokens += max( - int(getattr(usage, "output_tokens", 0) or 0), 0 - ) - self._total_latency_ms += max( - int(round(float(getattr(usage, "time", 0) or 0) * 1000)), 0 - ) - metadata = getattr(usage, "metadata", None) - if metadata is not None: - cached_tokens = 0 - if isinstance(metadata, dict): - prompt_details = metadata.get("prompt_tokens_details") - if isinstance(prompt_details, dict): - cached_tokens = int(prompt_details.get("cached_tokens", 0) or 0) - else: - prompt_details = getattr(metadata, "prompt_tokens_details", None) - cached_tokens = int(getattr(prompt_details, "cached_tokens", 0) or 0) - self._cached_prompt_tokens += max(cached_tokens, 0) - - def usage_summary(self) -> dict[str, int]: - return { - "input_tokens": self._total_input_tokens, - "output_tokens": self._total_output_tokens, - "latency_ms": self._total_latency_ms, - "cached_prompt_tokens": self._cached_prompt_tokens, - } - - -class _PipelineStageEmitter: - def __init__( - self, - *, - pipeline: PipelineLike, - session_id: str, - run_id: str, - stage: str, - emit_text_events: bool, - emit_tool_events: bool, - ) -> None: - self._pipeline = pipeline - self._session_id = session_id - self._run_id = run_id - self._stage = stage - self._emit_text_events = emit_text_events - self._emit_tool_events = emit_tool_events - self._text_by_message_id: dict[str, str] = {} - self._emitted_tool_calls: set[str] = set() - self._emitted_tool_results: set[str] = set() - self.latest_text_message_id: str | None = None - self.latest_text: str = "" - - async def handle_print(self, *, msg: Msg, last: bool) -> None: - del last - if self._emit_tool_events: - await self._emit_tool_events_from_msg(msg) - if self._emit_text_events: - await self._emit_text_events_from_msg(msg) - - async def _emit_text_events_from_msg(self, msg: Msg) -> None: - text = msg.get_text_content(separator="") or "" - if not text: - return - message_id = str(msg.id) - self._text_by_message_id[message_id] = text - self.latest_text_message_id = message_id - self.latest_text = text - - async def _emit_tool_events_from_msg(self, msg: Msg) -> None: - for block in msg.get_content_blocks("tool_use"): - tool_call_id = str(block.get("id", "")).strip() - tool_name = str(block.get("name", "")).strip() - if ( - not tool_call_id - or not tool_name - or tool_call_id in self._emitted_tool_calls - ): - continue - payload = { - "messageId": str(msg.id), - "toolCallId": tool_call_id, - "toolCallName": tool_name, - "stage": self._stage, - } - await self._emit("TOOL_CALL_START", payload) - await self._emit( - "TOOL_CALL_ARGS", - { - **payload, - "args": block.get("input", {}), - }, - ) - await self._emit("TOOL_CALL_END", payload) - self._emitted_tool_calls.add(tool_call_id) - - for block in msg.get_content_blocks("tool_result"): - tool_call_id = str(block.get("id", "")).strip() - if not tool_call_id or tool_call_id in self._emitted_tool_results: - continue - tool_output = parse_tool_agent_output(block.get("output")) - if tool_output is None: - continue - - tool_output_dict = tool_output.model_dump(mode="json", exclude_none=True) - - result_data = { - "messageId": str(msg.id), - "role": "tool", - "stage": self._stage, - "tool_name": tool_output.tool_name, - "tool_call_id": tool_output.tool_call_id, - "tool_call_args": tool_output.tool_call_args, - "status": tool_output.status.value, - "result_summary": tool_output.result_summary, - } - ui_hints = tool_output_dict.get("ui_hints") - if ui_hints is not None: - result_data["ui_hints"] = ui_hints - if tool_output.error: - result_data["error"] = tool_output.error.model_dump(mode="json") - - await self._emit("TOOL_CALL_RESULT", result_data) - self._emitted_tool_results.add(tool_call_id) - - async def emit_final_text_end( - self, - *, - worker_output: dict[str, Any], - response_metadata: dict[str, Any], - ) -> None: - message_id = ( - self.latest_text_message_id or f"worker-{self._run_id}-{uuid4().hex[:8]}" - ) - - output_data = { - "messageId": message_id, - "role": "assistant", - "stage": self._stage, - "status": worker_output.get("status"), - "answer": worker_output.get("answer", ""), - "key_points": worker_output.get("key_points", []), - "result_type": worker_output.get("result_type"), - "suggested_actions": worker_output.get("suggested_actions", []), - "error": worker_output.get("error"), - } - ui_hints = worker_output.get("ui_hints") - if ui_hints is not None: - output_data["ui_hints"] = ui_hints - - output_data.update(response_metadata) - - await self._emit("TEXT_MESSAGE_END", output_data) - - async def _emit(self, event_type: str, payload: dict[str, Any]) -> None: - await self._pipeline.emit( - session_id=self._session_id, - event={ - "type": event_type, - "threadId": self._session_id, - "runId": self._run_id, - **payload, - }, - ) - - class AgentScopeRunner: def __init__(self, *, litellm_service: LiteLLMService | None = None) -> None: + patch_agentscope_json_repair_compat() self._litellm_service = litellm_service or LiteLLMService() async def execute( @@ -310,76 +70,30 @@ class AgentScopeRunner: run_input: RunAgentInput, ) -> dict[str, Any]: owner_id = UUID(user_context.id) - enabled_tool_names = self._extract_tool_names(run_input) async with AsyncSessionLocal() as session: - router_toolkit, worker_toolkit = self._build_toolkits( - session=session, - owner_id=owner_id, - enabled_tool_names=enabled_tool_names, + worker_toolkit = self._build_worker_toolkit( + session=session, owner_id=owner_id + ) + router_config, worker_config = await self._load_stage_configs( + session=session ) - router_config = await self._load_system_agent_config( + router_output = await self._execute_router_step( session=session, - agent_type=AgentType.ROUTER, - ) - worker_config = await self._load_system_agent_config( - session=session, - agent_type=AgentType.WORKER, - ) - - await self._emit_step_event( pipeline=pipeline, run_input=run_input, - step_name="router", - event_type="STEP_STARTED", - ) - router_result = await self._run_router_stage( user_context=user_context, context_messages=context_messages, - toolkit=router_toolkit, - run_input=run_input, stage_config=router_config, ) - router_output = RouterAgentOutput.model_validate(router_result.payload) - await self._persist_router_message( - session=session, - thread_id=run_input.thread_id, - run_id=run_input.run_id, - model_code=router_config.model_code, - router_output=router_output, - response_metadata=router_result.response_metadata, - ) - await session.commit() - await self._emit_step_event( + worker_output = await self._execute_worker_step( pipeline=pipeline, run_input=run_input, - step_name="router", - event_type="STEP_FINISHED", - ) - - worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode) - await self._emit_step_event( - pipeline=pipeline, - run_input=run_input, - step_name="worker", - event_type="STEP_STARTED", - ) - worker_result = await self._run_worker_stage( user_context=user_context, router_output=router_output, toolkit=worker_toolkit, - run_input=run_input, stage_config=worker_config, - worker_output_model=worker_output_model, - pipeline=pipeline, - ) - worker_output = worker_output_model.model_validate(worker_result.payload) - await self._emit_step_event( - pipeline=pipeline, - run_input=run_input, - step_name="worker", - event_type="STEP_FINISHED", ) return { @@ -387,40 +101,107 @@ class AgentScopeRunner: "worker": worker_output.model_dump(mode="json", exclude_none=True), } - def _build_toolkits( + def _build_worker_toolkit( self, *, session: AsyncSession, owner_id: UUID, - enabled_tool_names: set[str] | None, - ) -> tuple[Any, Any]: - return ( - build_toolkit( - session=session, - owner_id=owner_id, - enabled_tool_names=set(), - ), - build_stage_toolkit( - agent_type=AgentType.WORKER, - session=session, - owner_id=owner_id, - enabled_tool_names=enabled_tool_names, - ), + ) -> Any: + return build_stage_toolkit( + agent_type=AgentType.WORKER, + session=session, + owner_id=owner_id, ) - def _extract_tool_names(self, run_input: RunAgentInput) -> set[str] | None: - raw_tools = getattr(run_input, "tools", None) - if not isinstance(raw_tools, list): - return None - selected: set[str] = set() - for item in raw_tools: - if isinstance(item, dict): - name = item.get("name") - else: - name = getattr(item, "name", None) - if isinstance(name, str) and name.strip(): - selected.add(normalize_tool_name(name)) - return selected + async def _load_stage_configs( + self, + *, + session: AsyncSession, + ) -> tuple[SystemAgentRuntimeConfig, SystemAgentRuntimeConfig]: + router_config = await self._load_system_agent_config( + session=session, + agent_type=AgentType.ROUTER, + ) + worker_config = await self._load_system_agent_config( + session=session, + agent_type=AgentType.WORKER, + ) + return router_config, worker_config + + async def _execute_router_step( + self, + *, + session: AsyncSession, + pipeline: PipelineLike, + run_input: RunAgentInput, + user_context: UserContext, + context_messages: list[Msg], + stage_config: SystemAgentRuntimeConfig, + ) -> RouterAgentOutput: + await self._emit_step_event( + pipeline=pipeline, + run_input=run_input, + step_name="router", + event_type="STEP_STARTED", + ) + router_result = await self._run_router_stage( + user_context=user_context, + context_messages=context_messages, + run_input=run_input, + stage_config=stage_config, + ) + router_output = RouterAgentOutput.model_validate(router_result.payload) + await persist_router_message( + session=session, + thread_id=run_input.thread_id, + run_id=run_input.run_id, + model_code=stage_config.model_code, + router_output=router_output, + response_metadata=router_result.response_metadata, + ) + await session.commit() + await self._emit_step_event( + pipeline=pipeline, + run_input=run_input, + step_name="router", + event_type="STEP_FINISHED", + ) + return router_output + + async def _execute_worker_step( + self, + *, + pipeline: PipelineLike, + run_input: RunAgentInput, + user_context: UserContext, + router_output: RouterAgentOutput, + toolkit: Any, + stage_config: SystemAgentRuntimeConfig, + ) -> WorkerAgentOutputLite: + worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode) + await self._emit_step_event( + pipeline=pipeline, + run_input=run_input, + step_name="worker", + event_type="STEP_STARTED", + ) + worker_result = await self._run_worker_stage( + user_context=user_context, + router_output=router_output, + toolkit=toolkit, + run_input=run_input, + stage_config=stage_config, + worker_output_model=worker_output_model, + pipeline=pipeline, + ) + worker_output = worker_output_model.model_validate(worker_result.payload) + await self._emit_step_event( + pipeline=pipeline, + run_input=run_input, + step_name="worker", + event_type="STEP_FINISHED", + ) + return worker_output async def _load_system_agent_config( self, @@ -451,7 +232,6 @@ class AgentScopeRunner: *, user_context: UserContext, context_messages: list[Msg], - toolkit: Any, run_input: RunAgentInput, stage_config: SystemAgentRuntimeConfig, ) -> StageExecutionResult: @@ -462,28 +242,26 @@ class AgentScopeRunner: now_utc=datetime.now(timezone.utc), tools=None, ) - agent = self._build_agent( - agent_name="router", - system_prompt=system_prompt, - toolkit=toolkit, + response, payload = await finalize_json_response( model=tracking_model, - ) - response_msg = await agent.reply_json( - context_messages, + formatter=OpenAIChatFormatter(), + base_messages=[Msg("system", system_prompt, "system"), *context_messages], output_model=RouterAgentOutput, + retries=0, ) + response_msg = Msg( + name="router", + role="assistant", + content=list(getattr(response, "content", [])), + metadata=payload, + ) + logger.info( "router_reply_received", run_id=run_input.run_id, thread_id=run_input.thread_id, message_id=str(response_msg.id), ) - payload = RouterAgentOutput.model_validate( - response_msg.metadata or {} - ).model_dump( - mode="json", - exclude_none=True, - ) return StageExecutionResult( message=response_msg, payload=payload, @@ -504,11 +282,9 @@ class AgentScopeRunner: worker_output_model: type[WorkerAgentOutputLite], pipeline: PipelineLike, ) -> StageExecutionResult: - worker_input = self._build_worker_input_messages( - router_output=router_output, - ) + worker_input = self._build_worker_input_messages(router_output=router_output) tracking_model = self._build_model(stage_config=stage_config) - emitter = _PipelineStageEmitter( + emitter = PipelineStageEmitter( pipeline=pipeline, session_id=run_input.thread_id, run_id=run_input.run_id, @@ -522,15 +298,14 @@ class AgentScopeRunner: agent_type=AgentType.WORKER, user_context=user_context, now_utc=datetime.now(timezone.utc), - tools=run_input.tools, + tools=None, ), toolkit=toolkit, model=tracking_model, emitter=emitter, ) response_msg = await agent.reply_json( - worker_input, - output_model=worker_output_model, + worker_input, output_model=worker_output_model ) worker_payload = worker_output_model.model_validate(response_msg.metadata or {}) response_metadata = self._litellm_service.build_usage_metadata( @@ -552,24 +327,17 @@ class AgentScopeRunner: *, router_output: RouterAgentOutput, ) -> list[Msg]: - routing_contract = json.dumps( - router_output.model_dump(mode="json", exclude_none=True), - ensure_ascii=False, - separators=(",", ":"), - ) - routing_msg = Msg( - name="router", - role="user", - content=( - "Use the following routing contract as the execution source of truth. " - f"Do not change the routed objective:\n{routing_contract}" - ), - ) - return [routing_msg] + return [ + Msg( + name="router", + role="user", + content=build_worker_contract_prompt(router_output=router_output), + ) + ] def _build_model( self, *, stage_config: SystemAgentRuntimeConfig - ) -> _TrackingChatModel: + ) -> TrackingChatModel: generate_kwargs: dict[str, Any] = { "temperature": stage_config.llm_config.temperature, "max_tokens": stage_config.llm_config.max_tokens, @@ -585,7 +353,7 @@ class AgentScopeRunner: client_kwargs={"base_url": self._litellm_service.proxy_base_url}, generate_kwargs=generate_kwargs, ) - return _TrackingChatModel(model) + return TrackingChatModel(model) def _build_agent( self, @@ -593,8 +361,8 @@ class AgentScopeRunner: agent_name: str, system_prompt: str, toolkit: Any, - model: _TrackingChatModel, - emitter: _PipelineStageEmitter | None = None, + model: TrackingChatModel, + emitter: PipelineStageEmitter | None = None, ) -> JsonReActAgent: return JsonReActAgent( name=agent_name, @@ -624,66 +392,5 @@ class AgentScopeRunner: }, ) - async def _persist_router_message( - self, - *, - session: AsyncSession, - thread_id: str, - run_id: str, - model_code: str, - router_output: RouterAgentOutput, - response_metadata: dict[str, Any], - ) -> None: - session_id = UUID(thread_id) - message_repo = MessageRepository(session) - session_repo = SessionRepository(session) - locked_session = await session_repo.lock_session_for_update( - session_id=session_id - ) - if locked_session is None: - raise RuntimeError("chat session not found for router persistence") - seq = int(getattr(locked_session, "message_count", 0) or 0) + 1 - metadata = AgentChatMessageMetadata( - run_id=run_id, - agent_type=AgentType.ROUTER, - router_agent_output=router_output, - ) - message_payload = AgentChatMessage( - id=uuid4(), - seq=seq, - role=AgentChatMessageRole.ASSISTANT.value, - content="", - model_code=model_code, - tool_name=None, - input_tokens=int(response_metadata.get("inputTokens", 0) or 0), - output_tokens=int(response_metadata.get("outputTokens", 0) or 0), - cost=Decimal(str(response_metadata.get("cost", 0) or 0)), - latency_ms=int(response_metadata.get("latencyMs", 0) or 0), - metadata=metadata, - timestamp=datetime.now(timezone.utc), - ) - await message_repo.append_message( - session_id=session_id, - seq=message_payload.seq, - role=AgentChatMessageRole.ASSISTANT, - content=message_payload.content, - model_code=message_payload.model_code, - tool_name=message_payload.tool_name, - metadata=metadata.model_dump(mode="json", exclude_none=True), - input_tokens=message_payload.input_tokens, - output_tokens=message_payload.output_tokens, - cost=message_payload.cost, - latency_ms=message_payload.latency_ms, - ) - await session_repo.update_runtime_state( - chat_session=locked_session, - status=AgentChatSessionStatus.RUNNING, - state_snapshot=locked_session.state_snapshot or {}, - message_delta=1, - token_delta=message_payload.input_tokens + message_payload.output_tokens, - cost_delta=message_payload.cost, - ) - await session.flush() - AgentScopeReActRunner = AgentScopeRunner diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py new file mode 100644 index 0000000..6b90439 --- /dev/null +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from typing import Any, Protocol +from uuid import uuid4 + +from agentscope.message import Msg + +from core.agentscope.utils import parse_tool_agent_output + + +class PipelineLike(Protocol): + async def emit(self, *, session_id: str, event: dict[str, Any]) -> str: ... + + +class PipelineStageEmitter: + def __init__( + self, + *, + pipeline: PipelineLike, + session_id: str, + run_id: str, + stage: str, + emit_text_events: bool, + emit_tool_events: bool, + ) -> None: + self._pipeline = pipeline + self._session_id = session_id + self._run_id = run_id + self._stage = stage + self._emit_text_events = emit_text_events + self._emit_tool_events = emit_tool_events + self._emitted_tool_calls: set[str] = set() + self._emitted_tool_results: set[str] = set() + self.latest_text_message_id: str | None = None + self.latest_text: str = "" + + async def handle_print(self, *, msg: Msg, last: bool) -> None: + del last + if self._emit_tool_events: + await self._emit_tool_events_from_msg(msg) + if self._emit_text_events: + await self._emit_text_events_from_msg(msg) + + async def emit_final_text_end( + self, + *, + worker_output: dict[str, Any], + response_metadata: dict[str, Any], + ) -> None: + message_id = ( + self.latest_text_message_id or f"worker-{self._run_id}-{uuid4().hex[:8]}" + ) + payload = { + "messageId": message_id, + "role": "assistant", + "stage": self._stage, + "status": worker_output.get("status"), + "answer": worker_output.get("answer", ""), + "key_points": worker_output.get("key_points", []), + "result_type": worker_output.get("result_type"), + "suggested_actions": worker_output.get("suggested_actions", []), + "error": worker_output.get("error"), + **response_metadata, + } + ui_hints = worker_output.get("ui_hints") + if ui_hints is not None: + payload["ui_hints"] = ui_hints + await self._emit("TEXT_MESSAGE_END", payload) + + async def _emit_text_events_from_msg(self, msg: Msg) -> None: + text = msg.get_text_content(separator="") or "" + if not text: + return + self.latest_text_message_id = str(msg.id) + self.latest_text = text + + async def _emit_tool_events_from_msg(self, msg: Msg) -> None: + for block in msg.get_content_blocks("tool_use"): + tool_call_id = str(block.get("id", "")).strip() + tool_name = str(block.get("name", "")).strip() + if ( + not tool_call_id + or not tool_name + or tool_call_id in self._emitted_tool_calls + ): + continue + base_payload = { + "messageId": str(msg.id), + "toolCallId": tool_call_id, + "toolCallName": tool_name, + "stage": self._stage, + } + await self._emit("TOOL_CALL_START", base_payload) + await self._emit( + "TOOL_CALL_ARGS", {**base_payload, "args": block.get("input", {})} + ) + await self._emit("TOOL_CALL_END", base_payload) + self._emitted_tool_calls.add(tool_call_id) + + for block in msg.get_content_blocks("tool_result"): + tool_call_id = str(block.get("id", "")).strip() + if not tool_call_id or tool_call_id in self._emitted_tool_results: + continue + tool_output = parse_tool_agent_output(block.get("output")) + if tool_output is None: + continue + payload = { + "messageId": str(msg.id), + "role": "tool", + "stage": self._stage, + "tool_name": tool_output.tool_name, + "tool_call_id": tool_output.tool_call_id, + "tool_call_args": tool_output.tool_call_args, + "status": tool_output.status.value, + "result_summary": tool_output.result_summary, + } + ui_hints = tool_output.model_dump(mode="json", exclude_none=True).get( + "ui_hints" + ) + if ui_hints is not None: + payload["ui_hints"] = ui_hints + if tool_output.error: + payload["error"] = tool_output.error.model_dump(mode="json") + + await self._emit("TOOL_CALL_RESULT", payload) + self._emitted_tool_results.add(tool_call_id) + + async def _emit(self, event_type: str, payload: dict[str, Any]) -> None: + await self._pipeline.emit( + session_id=self._session_id, + event={ + "type": event_type, + "threadId": self._session_id, + "runId": self._run_id, + **payload, + }, + ) diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index a75aff7..38aa6c7 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -from typing import Any +from typing import Any, cast from uuid import UUID from agentscope.message import Msg @@ -21,10 +21,12 @@ from core.taskiq.app import bulk_broker, critical_broker, default_broker from schemas.user import UserContext from services.base.redis import get_or_init_redis_client from services.base.supabase import supabase_service +from schemas.messages.chat_message import extract_user_message_attachments from v1.agent.dependencies import get_agent_service from v1.users.dependencies import get_user_service logger = get_logger("core.agentscope.runtime.tasks") +_MAX_CONTEXT_ATTACHMENTS = 3 def _load_runtime() -> type[Any]: @@ -63,38 +65,43 @@ async def _build_recent_context_messages( metadata = msg.get("metadata") if role == "user" and metadata: - attachments = metadata.get("user_message_attachments") - if attachments: - bucket = attachments.get("bucket") - path = attachments.get("path") - mime_type = attachments.get("mime_type") - if bucket and path: - try: - image_bytes = await supabase_service.download_bytes( - bucket=bucket, - path=path, - ) - b64_data = base64.b64encode(image_bytes).decode("utf-8") - converted.append( - Msg( - name="user", - role="user", - content=[ - {"type": "text", "text": content}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": mime_type or "image/png", - "data": b64_data, - }, - }, - ], - ) - ) - continue - except Exception: - pass + image_blocks: list[dict[str, Any]] = [] + attachments = extract_user_message_attachments(metadata)[ + :_MAX_CONTEXT_ATTACHMENTS + ] + for attachment in attachments: + try: + image_bytes = await supabase_service.download_bytes( + bucket=attachment.bucket, + path=attachment.path, + ) + except Exception: + continue + b64_data = base64.b64encode(image_bytes).decode("utf-8") + image_blocks.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": attachment.mime_type or "image/png", + "data": b64_data, + }, + } + ) + + if image_blocks: + multimodal_content: list[dict[str, Any]] = [] + if isinstance(content, str) and content: + multimodal_content.append({"type": "text", "text": content}) + multimodal_content.extend(image_blocks) + converted.append( + Msg( + name="user", + role="user", + content=cast(Any, multimodal_content), + ) + ) + continue if role == "tool": role = "assistant" diff --git a/backend/src/core/agentscope/utils/__init__.py b/backend/src/core/agentscope/utils/__init__.py new file mode 100644 index 0000000..b500498 --- /dev/null +++ b/backend/src/core/agentscope/utils/__init__.py @@ -0,0 +1,23 @@ +from core.agentscope.utils.compat import ( + patch_agentscope_json_repair_compat, + safe_json_loads_with_repair, +) +from core.agentscope.utils.json_finalize import ( + build_json_finalize_instruction, + finalize_json_response, +) +from core.agentscope.utils.parsing import ( + extract_text_content, + parse_json_dict, + parse_tool_agent_output, +) + +__all__ = [ + "build_json_finalize_instruction", + "extract_text_content", + "finalize_json_response", + "parse_json_dict", + "parse_tool_agent_output", + "patch_agentscope_json_repair_compat", + "safe_json_loads_with_repair", +] diff --git a/backend/src/core/agentscope/utils/compat.py b/backend/src/core/agentscope/utils/compat.py new file mode 100644 index 0000000..1f76fe4 --- /dev/null +++ b/backend/src/core/agentscope/utils/compat.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +from typing import Any + +from core.logging import get_logger + +logger = get_logger("core.agentscope.utils.compat") +_AGENTSCOPE_JSON_REPAIR_PATCHED = False + + +def safe_json_loads_with_repair(json_str: str) -> dict[str, Any]: + try: + from json_repair import repair_json + + repair_json_any: Any = repair_json + try: + repaired = repair_json_any(json_str, **{"stream_stable": True}) + except TypeError: + repaired = repair_json_any(json_str) + + if isinstance(repaired, dict): + return repaired + if isinstance(repaired, str): + loaded = json.loads(repaired) + return loaded if isinstance(loaded, dict) else {} + return {} + except Exception: # noqa: BLE001 + preview = json_str[:100] + "..." if len(json_str) > 100 else json_str + logger.warning("failed_to_parse_tool_arguments", preview=preview) + return {} + + +def patch_agentscope_json_repair_compat() -> None: + global _AGENTSCOPE_JSON_REPAIR_PATCHED + if _AGENTSCOPE_JSON_REPAIR_PATCHED: + return + + try: + from agentscope._utils import _common as common_mod + from agentscope.model import _openai_model as openai_model_mod + except Exception: # noqa: BLE001 + return + + common_mod._json_loads_with_repair = safe_json_loads_with_repair + openai_model_mod._json_loads_with_repair = safe_json_loads_with_repair + _AGENTSCOPE_JSON_REPAIR_PATCHED = True + logger.info("patched_agentscope_json_repair_compat") diff --git a/backend/src/core/agentscope/utils/json_finalize.py b/backend/src/core/agentscope/utils/json_finalize.py new file mode 100644 index 0000000..6dff5f7 --- /dev/null +++ b/backend/src/core/agentscope/utils/json_finalize.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +from collections.abc import Awaitable +from typing import Any, Protocol + +from agentscope.message import Msg +from pydantic import BaseModel, ValidationError + +from core.agentscope.utils.parsing import extract_text_content, parse_json_dict + + +class FormatterProtocol(Protocol): + def format(self, *args: Any, **kwargs: Any) -> Awaitable[Any]: ... + + +def build_json_finalize_instruction( + *, + schema_json: str, + attempt: int, + validation_error: str = "", +) -> str: + error_part = ( + "" + if not validation_error + else ( + "\n\n[Validation Error From Previous Attempt]\n" + f"{validation_error}\n" + "Fix all missing/invalid fields and regenerate." + ) + ) + return ( + "Return JSON only. Do not output markdown, prose, or code fences. " + "Follow this JSON Schema exactly and include all required fields. " + "Do not call tools.\n\n" + f"[Schema]\n{schema_json}\n\n" + f"[Attempt]\n{attempt}{error_part}" + ) + + +async def finalize_json_response( + *, + model: Any, + formatter: FormatterProtocol, + base_messages: list[Msg], + output_model: type[BaseModel], + retries: int, +) -> tuple[Any, dict[str, Any]]: + schema_json = json.dumps( + output_model.model_json_schema(), + ensure_ascii=True, + separators=(",", ":"), + ) + last_error = "" + + for attempt in range(1, retries + 2): + prompt = await formatter.format( + msgs=[ + *base_messages, + Msg( + "user", + build_json_finalize_instruction( + schema_json=schema_json, + attempt=attempt, + validation_error=last_error, + ), + "user", + ), + ] + ) + original_stream = model.stream + model.stream = False + try: + response = await model( + prompt, + tool_choice="none", + response_format={"type": "json_object"}, + ) + finally: + model.stream = original_stream + + raw_text = extract_text_content(getattr(response, "content", [])) + payload = parse_json_dict(raw_text) + if payload is None: + last_error = "Model output is not a valid JSON object." + continue + + try: + validated = output_model.model_validate(payload) + return response, validated.model_dump(mode="json", exclude_none=True) + except ValidationError as exc: + last_error = str(exc) + + raise RuntimeError( + f"failed to finalize structured output for {output_model.__name__}: {last_error}" + ) diff --git a/backend/src/core/agentscope/runtime/utils.py b/backend/src/core/agentscope/utils/parsing.py similarity index 80% rename from backend/src/core/agentscope/runtime/utils.py rename to backend/src/core/agentscope/utils/parsing.py index 66bed9a..ee0b34b 100644 --- a/backend/src/core/agentscope/runtime/utils.py +++ b/backend/src/core/agentscope/utils/parsing.py @@ -4,23 +4,9 @@ import json from collections.abc import Sequence from typing import Any -from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints from schemas.agent.runtime_models import ToolAgentOutput -def compile_ui_hints_safe(ui_hints: Any) -> dict[str, Any] | None: - if not ui_hints: - return None - try: - return compile_ui_hints(ui_hints) - except Exception: - return None - - -def normalize_tool_name(value: str) -> str: - return value.strip().replace(".", "_").replace("-", "_") - - def parse_tool_agent_output(output: Any) -> ToolAgentOutput | None: blocks = output if isinstance(output, Sequence) else [] for block in blocks: diff --git a/backend/src/schemas/messages/chat_message.py b/backend/src/schemas/messages/chat_message.py index 11f2d93..aad4317 100644 --- a/backend/src/schemas/messages/chat_message.py +++ b/backend/src/schemas/messages/chat_message.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal -from typing import ClassVar +from typing import Any, ClassVar from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -11,7 +11,7 @@ from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputRic from ..agent import AgentType, ToolAgentOutput -class UserMessageAttachments(BaseModel): +class UserMessageAttachment(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") bucket: str @@ -23,7 +23,7 @@ class AgentChatMessageMetadata(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow") run_id: str agent_type: AgentType | None = None - user_message_attachments: UserMessageAttachments | None = None + user_message_attachments: list[UserMessageAttachment] | None = None router_agent_output: RouterAgentOutput | None = None tool_agent_output: ToolAgentOutput | None = None worker_agent_output: WorkerAgentOutputRich | None = None @@ -46,3 +46,32 @@ class AgentChatMessage(BaseModel): latency_ms: int | None = Field(default=None, ge=0) metadata: AgentChatMessageMetadata | dict[str, object] | None = None timestamp: datetime + + +def extract_user_message_attachments( + metadata: AgentChatMessageMetadata | dict[str, object] | None, +) -> list[UserMessageAttachment]: + if metadata is None: + return [] + + if isinstance(metadata, AgentChatMessageMetadata): + raw_value: Any = metadata.user_message_attachments + else: + raw_value = metadata.get("user_message_attachments") + + if raw_value is None: + return [] + + raw_items: list[Any] + if isinstance(raw_value, list): + raw_items = raw_value + else: + raw_items = [raw_value] + + attachments: list[UserMessageAttachment] = [] + for item in raw_items: + try: + attachments.append(UserMessageAttachment.model_validate(item)) + except Exception: + continue + return attachments diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index 349a04f..73d66fa 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -37,6 +37,13 @@ class AttachmentSignedUrlResponse(BaseModel): url: str +class HistoryMessageAttachment(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + mime_type: str = Field(alias="mimeType") + url: str + + class HistoryMessage(BaseModel): """History message schema for /history endpoint response.""" @@ -46,9 +53,9 @@ class HistoryMessage(BaseModel): seq: int = Field(description="Message sequence number") role: str = Field(description="Message role: user | assistant | tool") content: str = Field(description="Message text content") - url: str | None = Field( - default=None, - description="Temporary signed URL for user-attached images", + attachments: list[HistoryMessageAttachment] = Field( + default_factory=list, + description="Temporary signed URLs for user-attached images", ) ui_schema: UiSchemaRenderer | None = Field( default=None, diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index eee98fa..027fdb9 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -19,7 +19,8 @@ from core.config.settings import config from core.logging import get_logger from schemas.messages.chat_message import ( AgentChatMessageMetadata, - UserMessageAttachments, + UserMessageAttachment, + extract_user_message_attachments, ) from v1.agent.schemas import HistorySnapshotResponse @@ -27,6 +28,7 @@ logger = get_logger(__name__) _ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"} _MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 _MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024 +_MAX_ATTACHMENTS_PER_MESSAGE = 3 @dataclass(frozen=True) @@ -230,7 +232,7 @@ class AgentService: ) -> tuple[str, AgentChatMessageMetadata | None]: text, content_blocks = extract_latest_user_payload(run_input) - user_attachments: UserMessageAttachments | None = None + user_attachments: list[UserMessageAttachment] = [] for block in content_blocks: if not isinstance(block, dict): continue @@ -257,12 +259,15 @@ class AgentService: thread_id=run_input.thread_id, current_user=current_user, ) - user_attachments = UserMessageAttachments( - bucket=bucket, - path=path, - mime_type=mime_type, + user_attachments.append( + UserMessageAttachment( + bucket=bucket, + path=path, + mime_type=mime_type, + ) ) - break + if len(user_attachments) > _MAX_ATTACHMENTS_PER_MESSAGE: + raise HTTPException(status_code=422, detail="Too many attachments") except HTTPException: raise except Exception as exc: # noqa: BLE001 @@ -270,7 +275,7 @@ class AgentService: raise HTTPException(status_code=422, detail="Invalid signed image url") metadata: AgentChatMessageMetadata | None = None - if user_attachments is not None: + if user_attachments: metadata = AgentChatMessageMetadata( run_id=run_input.run_id, user_message_attachments=user_attachments, @@ -438,23 +443,38 @@ class AgentService: messages: list[HistoryMessage] = [] if day_payload: - raw_messages = day_payload.get("messages") or [] + raw_messages_obj = day_payload.get("messages") + raw_messages = ( + raw_messages_obj if isinstance(raw_messages_obj, list) else [] + ) for msg_dict in raw_messages: msg = AgentChatMessage.model_validate(msg_dict) - signed_url: str | None = None - if self._attachment_storage and msg.metadata: - att = msg.metadata.user_message_attachments - if att: + signed_urls: dict[str, str] = {} + attachments = extract_user_message_attachments(msg.metadata) + if self._attachment_storage and attachments: + expected_prefix = ( + f"agent-inputs/{current_user.id}/{thread_id}/uploads/" + ) + for attachment in attachments: + if not _is_safe_attachment_path( + attachment.path, + expected_prefix=expected_prefix, + ): + continue signed_url = await self._attachment_storage.create_signed_url( - bucket=att.bucket, - path=att.path, + bucket=attachment.bucket, + path=attachment.path, expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS, ) + key = f"{attachment.bucket}/{attachment.path}" + signed_urls[key] = signed_url - converted = convert_message_to_history(msg, None) - if signed_url: - converted["url"] = signed_url + def _get_signed_url(payload: dict[str, str]) -> str: + key = f"{payload['bucket']}/{payload['path']}" + return signed_urls[key] + + converted = convert_message_to_history(msg, _get_signed_url) messages.append(HistoryMessage.model_validate(converted)) return HistorySnapshotResponse( diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index c6412c1..c3d8e9b 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -11,7 +11,7 @@ from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints from schemas.messages.chat_message import ( AgentChatMessage, AgentChatMessageMetadata, - UserMessageAttachments, + extract_user_message_attachments, ) @@ -23,7 +23,7 @@ def convert_message_to_history( 将 AgentChatMessage 转换为 HistoryMessage 格式 转换规则: - - role=user: 读取 metadata.user_message_attachments,将 bucket 转临时访问 url + - role=user: 读取 metadata.user_message_attachments,转换为 attachments[] - role=tool: 读取 content 和 metadata.tool_agent_output.ui_hints,编译成 ui_schema - role=assistant: 读取 metadata.worker_agent_output.ui_hints,编译成 ui_schema """ @@ -31,11 +31,11 @@ def convert_message_to_history( content = message.content metadata = message.metadata - url: str | None = None + attachments: list[dict[str, str]] = [] ui_schema: dict[str, Any] | None = None if role == "user": - url = _convert_user_attachments(metadata, get_signed_url_fn) + attachments = _convert_user_attachments(metadata, get_signed_url_fn) elif role == "tool": ui_schema = _compile_tool_ui_hints(metadata) @@ -51,8 +51,8 @@ def convert_message_to_history( "timestamp": message.timestamp.isoformat(), } - if url: - result["url"] = url + if attachments: + result["attachments"] = attachments if ui_schema: result["ui_schema"] = ui_schema @@ -63,28 +63,33 @@ def convert_message_to_history( def _convert_user_attachments( metadata: AgentChatMessageMetadata | dict[str, Any] | None, get_signed_url_fn: Callable[[dict[str, str]], str] | None, -) -> str | None: - """转换用户附件为临时访问 URL""" - if not metadata: - return None +) -> list[dict[str, str]]: + """转换用户附件为临时访问 URL 列表""" + if not metadata or not get_signed_url_fn: + return [] if isinstance(metadata, AgentChatMessageMetadata): - attachments = metadata.user_message_attachments + resolved = extract_user_message_attachments(metadata) + elif isinstance(metadata, dict): + resolved = extract_user_message_attachments(metadata) else: - attachments_data = metadata.get("user_message_attachments") - if not attachments_data: - return None - attachments = UserMessageAttachments.model_validate(attachments_data) + return [] - if not attachments or not get_signed_url_fn: - return None - - try: - return get_signed_url_fn( - {"bucket": attachments.bucket, "path": attachments.path} + signed_attachments: list[dict[str, str]] = [] + for attachment in resolved: + try: + signed_url = get_signed_url_fn( + {"bucket": attachment.bucket, "path": attachment.path} + ) + except Exception: + continue + signed_attachments.append( + { + "url": signed_url, + "mimeType": attachment.mime_type, + } ) - except Exception: - return None + return signed_attachments def _compile_tool_ui_hints( diff --git a/backend/src/v1/app/router.py b/backend/src/v1/app/router.py index e0ebf79..ee42ef6 100644 --- a/backend/src/v1/app/router.py +++ b/backend/src/v1/app/router.py @@ -25,13 +25,14 @@ class AppVersionInfo(BaseModel): router = APIRouter(prefix="/app", tags=["app"]) -def _parse_version(filename: str) -> tuple[str, int] | None: +def _parse_version(filename: str) -> tuple[str, int, tuple[int, ...]] | None: pattern = r"app[-_]v?(\d+\.\d+\.\d+)\+(\d+)\.(?:apk|ipa)" match = re.search(pattern, filename, re.IGNORECASE) if match: version = match.group(1) build = int(match.group(2)) - return (version, build) + version_tuple = tuple(int(x) for x in version.split(".")) + return (version, build, version_tuple) return None @@ -44,21 +45,32 @@ def _get_latest_release( if not base_path.exists(): return None - ext = "ipa" if platform == "ios" else "apk" + target_ext = "ipa" if platform == "ios" else "apk" candidates = [] + MIN_APK_SIZE = 1024 * 1024 # 1MB + MIN_IPA_SIZE = 1024 * 1024 # 1MB + for f in base_path.iterdir(): - if f.is_file() and f.suffix.lstrip(".").lower() == ext.lower(): - parsed = _parse_version(f.name) - if parsed: - version, build = parsed - candidates.append((version, build, f.name)) + if not f.is_file(): + continue + ext = f.suffix.lstrip(".").lower() + if ext != target_ext: + continue + # 简单校验文件大小,排除伪装文件 + if f.stat().st_size < (MIN_APK_SIZE if ext == "apk" else MIN_IPA_SIZE): + continue + parsed = _parse_version(f.name) + if parsed: + version, build, version_tuple = parsed + candidates.append((version_tuple, build, f.name)) if not candidates: return None candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) - return candidates[0][0], candidates[0][1], candidates[0][2] + result = candidates[0] + return result[2].replace("+", "."), result[1], result[2] def _compare_versions( diff --git a/backend/tests/integration/v1/agent/test_sse_flow_live.py b/backend/tests/integration/v1/agent/test_sse_flow_live.py index 2cfb666..471cf86 100644 --- a/backend/tests/integration/v1/agent/test_sse_flow_live.py +++ b/backend/tests/integration/v1/agent/test_sse_flow_live.py @@ -186,9 +186,11 @@ async def test_agent_runs_events_history_live_with_image_input() -> None: assert user_messages metadata = user_messages[0].get("metadata") assert isinstance(metadata, dict) - user_attachment = metadata.get("user_message_attachments") - assert isinstance(user_attachment, dict) - assert isinstance(user_attachment.get("path"), str) + user_attachments = metadata.get("user_message_attachments") + assert isinstance(user_attachments, list) + assert user_attachments + assert isinstance(user_attachments[0], dict) + assert isinstance(user_attachments[0].get("path"), str) async with AsyncSessionLocal() as session: session_row = await session.get(AgentChatSession, UUID(thread_id)) @@ -214,6 +216,8 @@ async def test_agent_runs_events_history_live_with_image_input() -> None: ] assert user_rows metadata = user_rows[0].metadata_json or {} - user_attachment = metadata.get("user_message_attachments") - assert isinstance(user_attachment, dict) - assert isinstance(user_attachment.get("path"), str) + user_attachments = metadata.get("user_message_attachments") + assert isinstance(user_attachments, list) + assert user_attachments + assert isinstance(user_attachments[0], dict) + assert isinstance(user_attachments[0].get("path"), str) diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index ba1cb7f..c0cc272 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -9,6 +9,7 @@ from core.agentscope.runtime.runner import ( StageExecutionResult, SystemAgentRuntimeConfig, ) +from core.agentscope.utils import safe_json_loads_with_repair from schemas.agent.runtime_models import ( RouterAgentOutput, UiMode, @@ -54,18 +55,7 @@ def _run_input() -> RunAgentInput: "runId": "run-1", "state": {}, "messages": [{"id": "u1", "role": "user", "content": "hello"}], - "tools": [ - { - "name": "calendar.read", - "description": "read", - "parameters": {"type": "object"}, - }, - { - "name": "calendar-write", - "description": "write", - "parameters": {"type": "object"}, - }, - ], + "tools": [], "context": [], "forwardedProps": {}, } @@ -94,6 +84,30 @@ def _router_output(*, ui_mode: UiMode) -> RouterAgentOutput: ) +def test_build_worker_input_messages_includes_field_guide() -> None: + runner = AgentScopeRunner() + + messages = runner._build_worker_input_messages( + router_output=_router_output(ui_mode=UiMode.NONE) + ) + + assert len(messages) == 1 + content = str(messages[0].content) + assert "[Worker Contract]" in content + assert "Use normalized_task_input as objective text." in content + assert "multimodal_summary/key_entities/constraints" in content + assert "key_entities" in content + assert "constraints" in content + assert "Infer deterministic missing required tool args" in content + + +def test_safe_json_loads_with_repair_parses_valid_json() -> None: + parsed = safe_json_loads_with_repair('{"operation":"create","title":"test"}') + + assert parsed["operation"] == "create" + assert parsed["title"] == "test" + + @pytest.mark.asyncio async def test_execute_uses_router_ui_mode_to_select_worker_output_model( monkeypatch: pytest.MonkeyPatch, @@ -110,11 +124,6 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model( "core.agentscope.runtime.runner.AsyncSessionLocal", lambda: _FakeSessionCtx(_CommitSession()), ) - monkeypatch.setattr( - runner, - "_build_toolkits", - lambda **kwargs: ("router-toolkit", "worker-toolkit"), - ) async def _load_system_agent_config(**kwargs): return SystemAgentRuntimeConfig( @@ -147,7 +156,10 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model( async def _persist_router_message(**kwargs) -> None: assert kwargs["model_code"] == "qwen3.5-flash" - monkeypatch.setattr(runner, "_persist_router_message", _persist_router_message) + monkeypatch.setattr( + "core.agentscope.runtime.runner.persist_router_message", + _persist_router_message, + ) async def _run_worker_stage(**kwargs): worker_model_holder["model"] = kwargs["worker_output_model"] @@ -196,11 +208,3 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model( ] assert result["router"]["ui"]["ui_mode"] == "rich" assert result["worker"]["answer"] == "done" - - -def test_extract_tool_names_normalizes_client_tool_names() -> None: - runner = AgentScopeRunner() - - names = runner._extract_tool_names(_run_input()) - - assert names == {"calendar_read", "calendar_write"} diff --git a/backend/tests/unit/core/agentscope/runtime/test_tasks.py b/backend/tests/unit/core/agentscope/runtime/test_tasks.py index 6ab06f0..1477001 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_tasks.py +++ b/backend/tests/unit/core/agentscope/runtime/test_tasks.py @@ -174,3 +174,60 @@ async def test_run_agentscope_task_rejects_invalid_command_type() -> None: "run_input": _run_input_payload(), } ) + + +@pytest.mark.asyncio +async def test_build_recent_context_messages_includes_all_user_attachments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeAgentService: + async def load_agent_input_messages( + self, + *, + thread_id: str, + ) -> dict[str, object] | None: + del thread_id + return { + "messages": [ + { + "role": "user", + "content": "请看图片", + "metadata": { + "user_message_attachments": [ + { + "bucket": "bucket-1", + "path": "path/a.png", + "mime_type": "image/png", + }, + { + "bucket": "bucket-1", + "path": "path/b.jpg", + "mime_type": "image/jpeg", + }, + ] + }, + } + ] + } + + class _FakeSupabase: + async def download_bytes(self, *, bucket: str, path: str) -> bytes: + return f"{bucket}:{path}".encode("utf-8") + + monkeypatch.setattr( + tasks_module, "get_agent_service", lambda session: _FakeAgentService() + ) + monkeypatch.setattr(tasks_module, "supabase_service", _FakeSupabase()) + + messages = await tasks_module._build_recent_context_messages( + session=object(), + thread_id=str(uuid4()), + ) + + assert len(messages) == 1 + content = messages[0].content + assert isinstance(content, list) + assert len(content) == 3 + assert content[0]["type"] == "text" + assert content[1]["type"] == "image" + assert content[2]["type"] == "image" diff --git a/backend/tests/unit/core/agentscope/test_agent_prompt.py b/backend/tests/unit/core/agentscope/test_agent_prompt.py index 69b31e6..c84c331 100644 --- a/backend/tests/unit/core/agentscope/test_agent_prompt.py +++ b/backend/tests/unit/core/agentscope/test_agent_prompt.py @@ -5,7 +5,9 @@ from core.agentscope.prompts.agent_prompt import ( WORKER_AGENT_INSTRUCTION, build_agent_prompt, build_intent_user_prompt, + build_worker_contract_prompt, ) +from schemas.agent.runtime_models import RouterAgentOutput from schemas.agent.system_agent import AgentType @@ -39,6 +41,9 @@ def test_build_agent_prompt_for_worker_relies_on_injected_schema() -> None: assert "- type: worker" in prompt assert WORKER_AGENT_INSTRUCTION in prompt assert "execute routed objective" in prompt + assert "objective/constraints contract" in prompt + assert "Infer deterministic required tool arguments" in prompt + assert "Ask minimal clarification" in prompt assert "never fabricate execution state" in prompt assert ( "The worker output schema is injected at runtime; follow it exactly." in prompt @@ -46,3 +51,35 @@ def test_build_agent_prompt_for_worker_relies_on_injected_schema() -> None: assert "Do not add fields that are not present in the injected schema." in prompt assert "ui_mode=rich" not in prompt assert "ui_mode=none" not in prompt + + +def test_build_worker_contract_prompt_is_concise_and_actionable() -> None: + router_output = RouterAgentOutput.model_validate( + { + "normalized_task_input": { + "user_text": "创建明天 9 点会议", + "multimodal_summary": ["图片里有会议时间"], + }, + "key_entities": [ + { + "name": "start", + "type": "datetime", + "value": "2026-03-17T09:00:00+08:00", + } + ], + "constraints": [ + {"key": "timezone", "value": "Asia/Shanghai", "required": True} + ], + "task_typing": {"primary": "scheduling", "secondary": []}, + "execution_mode": "tool_assisted", + "result_typing": {"primary": "execution_report", "secondary": []}, + "ui": {"ui_mode": "none", "ui_decision_reason": "plain"}, + } + ) + + prompt = build_worker_contract_prompt(router_output=router_output) + + assert "[Worker Contract]" in prompt + assert "Infer deterministic missing required tool args" in prompt + assert "[RouterAgentOutput]" in prompt + assert '"execution_mode":"tool_assisted"' in prompt diff --git a/backend/tests/unit/v1/agent/test_service.py b/backend/tests/unit/v1/agent/test_service.py index ac6045e..097c192 100644 --- a/backend/tests/unit/v1/agent/test_service.py +++ b/backend/tests/unit/v1/agent/test_service.py @@ -126,7 +126,10 @@ def _user() -> CurrentUser: ) -def _build_run_input(*, url: str) -> RunAgentInput: +def _build_run_input(*, urls: list[str]) -> RunAgentInput: + content: list[dict[str, str]] = [{"type": "text", "text": "hello"}] + for url in urls: + content.append({"type": "binary", "mimeType": "image/png", "url": url}) return RunAgentInput.model_validate( { "threadId": "00000000-0000-0000-0000-000000000001", @@ -136,10 +139,7 @@ def _build_run_input(*, url: str) -> RunAgentInput: { "id": "u1", "role": "user", - "content": [ - {"type": "text", "text": "hello"}, - {"type": "binary", "mimeType": "image/png", "url": url}, - ], + "content": content, } ], "tools": [], @@ -161,7 +161,9 @@ async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> N attachment_storage=_FakeAttachmentStorage(), ) run_input = _build_run_input( - url="https://evil.example.com/storage/v1/object/sign/agent-test-bucket/a.png?token=1" + urls=[ + "https://evil.example.com/storage/v1/object/sign/agent-test-bucket/a.png?token=1" + ] ) with pytest.raises(HTTPException) as exc_info: @@ -191,8 +193,15 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token( "agent-inputs/00000000-0000-0000-0000-000000000001/" "00000000-0000-0000-0000-000000000001/uploads/a.png" ) + safe_path_two = quote( + "agent-inputs/00000000-0000-0000-0000-000000000001/" + "00000000-0000-0000-0000-000000000001/uploads/b.png" + ) run_input = _build_run_input( - url=f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1" + urls=[ + f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1", + f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path_two}?token=1", + ] ) accepted = await service.enqueue_run(run_input=run_input, current_user=_user()) @@ -201,9 +210,10 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token( persisted = repository.persisted_user_messages[0] metadata = cast(AgentChatMessageMetadata | None, persisted["metadata"]) assert metadata is not None - attachment = metadata.user_message_attachments - assert attachment is not None - assert attachment.bucket == "agent-test-bucket" + attachments = metadata.user_message_attachments + assert attachments is not None + assert len(attachments) == 2 + assert attachments[0].bucket == "agent-test-bucket" command = queue.commands[0] assert "user_token" not in command run_input = command["run_input"] @@ -257,3 +267,47 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path( ) assert exc_info.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None: + monkeypatch.setattr( + agent_service_module.config.storage, "bucket", "agent-test-bucket" + ) + service = AgentService( + repository=_FakeRepository(), + queue=_FakeQueue(), + stream=_FakeStream(), + attachment_storage=_FakeAttachmentStorage(), + ) + base_url = str(config.supabase.url).rstrip("/") + safe_paths = [ + quote( + "agent-inputs/00000000-0000-0000-0000-000000000001/" + "00000000-0000-0000-0000-000000000001/uploads/a.png" + ), + quote( + "agent-inputs/00000000-0000-0000-0000-000000000001/" + "00000000-0000-0000-0000-000000000001/uploads/b.png" + ), + quote( + "agent-inputs/00000000-0000-0000-0000-000000000001/" + "00000000-0000-0000-0000-000000000001/uploads/c.png" + ), + quote( + "agent-inputs/00000000-0000-0000-0000-000000000001/" + "00000000-0000-0000-0000-000000000001/uploads/d.png" + ), + ] + run_input = _build_run_input( + urls=[ + f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1" + for safe_path in safe_paths + ] + ) + + with pytest.raises(HTTPException) as exc_info: + await service.enqueue_run(run_input=run_input, current_user=_user()) + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == "Too many attachments" diff --git a/backend/tests/unit/v1/agent/test_utils.py b/backend/tests/unit/v1/agent/test_utils.py index 1b52d41..74f3600 100644 --- a/backend/tests/unit/v1/agent/test_utils.py +++ b/backend/tests/unit/v1/agent/test_utils.py @@ -48,3 +48,26 @@ def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() - assert "ui_schema" in result assert "uiSchema" not in result assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}} + + +def test_convert_message_to_history_returns_multiple_user_attachments() -> None: + message = _FakeMessage( + role="user", + metadata={ + "user_message_attachments": [ + {"bucket": "bucket-a", "path": "path/a.png", "mime_type": "image/png"}, + {"bucket": "bucket-a", "path": "path/b.jpg", "mime_type": "image/jpeg"}, + ] + }, + ) + + def _signed(payload: dict[str, str]) -> str: + return f"https://signed.example/{payload['bucket']}/{payload['path']}" + + result = convert_message_to_history(message, _signed) # type: ignore[arg-type] + + attachments = result.get("attachments") + assert isinstance(attachments, list) + assert len(attachments) == 2 + assert attachments[0]["mimeType"] == "image/png" + assert attachments[1]["mimeType"] == "image/jpeg" diff --git a/docs/plans/2026-03-16-account-and-chat-surface-language-implementation.md b/docs/plans/2026-03-16-account-and-chat-surface-language-implementation.md new file mode 100644 index 0000000..29a22c1 --- /dev/null +++ b/docs/plans/2026-03-16-account-and-chat-surface-language-implementation.md @@ -0,0 +1,86 @@ +# 账户页与主页工具渲染统一表面语言 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 统一 `我的账户` 页面与主页 `ui_schema` 渲染的视觉语言,修正 STEP 协议映射并将等待文案从“正在思考”切换为协议阶段语义。 + +**Architecture:** 设置域继续复用 `AccountSurfaceScaffold + AccountSectionCard`;聊天域在 `UiSchemaRenderer` 中重做 surface 层级与按钮/徽章/KV 呈现,保持协议字段不变。将 stage 映射改为 `router/worker` 协议值,在 UI 显示为“意图识别中/任务执行中/任务处理中”。 + +**Tech Stack:** Flutter, flutter_bloc, design tokens (`AppColors/AppSpacing/AppRadius`), AG-UI SSE protocol mapping + +--- + +### Task 1: 先补失败测试(Stage 文案与 UI Schema 呈现) + +**Files:** +- Create: `apps/test/features/chat/presentation/agent_stage_mapping_test.dart` +- Modify: `apps/test/features/chat/ui_schema_renderer_test.dart` + +**Step 1: 写失败测试** +- 新增 stage 映射测试,断言 `router -> intentLabel`、`worker -> executionLabel`、未知 -> processingLabel +- 扩展 `ui_schema_renderer_test.dart`,断言 card surface、按钮文案、badge 仍可渲染 + +**Step 2: 跑测试确认失败** + +Run: `flutter test test/features/chat/presentation/agent_stage_mapping_test.dart test/features/chat/ui_schema_renderer_test.dart` +Expected: FAIL(映射函数/新视觉结构尚未实现) + +**Step 3: 最小实现使测试通过** +- 新增 stage 映射文件并接入 chat/home +- 重构 `UiSchemaRenderer` 的 surface 风格并保持现有 schema 兼容 + +**Step 4: 再跑测试确认通过** + +Run: `flutter test test/features/chat/presentation/agent_stage_mapping_test.dart test/features/chat/ui_schema_renderer_test.dart` +Expected: PASS + +### Task 2: 重构我的账户页面为统一表面语言 + +**Files:** +- Modify: `apps/lib/features/settings/ui/screens/account_screen.dart` + +**Step 1: 写失败测试(轻量)** +- 可选新增页面结构测试,断言标题/分组/退出按钮存在 + +**Step 2: 运行测试确认失败(若新增测试)** + +Run: `flutter test test/features/settings/ui/screens/...` +Expected: FAIL + +**Step 3: 最小实现** +- 用 `AccountSurfaceScaffold` 承载页面 +- 用 `AccountSectionCard` 承载账户菜单与安全操作分组 +- 退出登录按钮改为统一 token 风格 + +**Step 4: 测试通过** + +Run: `flutter test test/features/settings/ui/screens/...` +Expected: PASS + +### Task 3: 协议对齐与主页等待文案修正 + +**Files:** +- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Create: `apps/lib/features/chat/presentation/bloc/agent_stage.dart` + +**Step 1: 保持协议语义** +- 将 step 映射改为文档约定:`router`、`worker` +- 显示文案:`意图识别中`、`任务执行中`、`任务处理中` + +**Step 2: 运行相关测试** + +Run: `flutter test test/features/chat/presentation/agent_stage_mapping_test.dart test/features/home/ui/widgets/home_screen_layout_test.dart` +Expected: PASS + +### Task 4: 全量验证 + +**Step 1: 静态检查** + +Run: `flutter analyze` +Expected: 无新增错误 + +**Step 2: 回归测试** + +Run: `flutter test` +Expected: PASS diff --git a/docs/plans/2026-03-16-settings-account-subpages-design.md b/docs/plans/2026-03-16-settings-account-subpages-design.md new file mode 100644 index 0000000..8439bd1 --- /dev/null +++ b/docs/plans/2026-03-16-settings-account-subpages-design.md @@ -0,0 +1,99 @@ +# 设置-账户子页面视觉重构设计(编辑资料 / 修改密码) + +## 背景与目标 + +当前 `编辑资料` 与 `修改密码` 子页面存在明显的“文档页/默认表单堆叠”观感,未满足 `apps/rules/visual_design_language.md` 要求的“高级、柔和、分层、助手产品感”。 + +本次设计目标: + +- 延续项目现有蓝灰体系,不改变品牌语气 +- 建立清晰的表面层级:背景层 -> 主内容层 -> 次级分组层 -> 交互强调层 +- 将 `修改密码` 对齐 `忘记密码` 的设计语言(分段、状态提示、密码输入体验),但保持“设置域”语义 +- 严格使用 `AppColors` / `AppSpacing` / `AppRadius`,移除新增硬编码视觉值 + +## 约束来源 + +- `apps/AGENTS.md` +- `apps/rules/visual_design_language.md` +- 现有共享组件体系(`AppButton`、`AppBanner`、`FixedLengthCodeInput`、Toast 系统) + +## 方案选择 + +已确认采用方案 B:设置域统一壳层。 + +方案要点: + +- 在 settings feature 内建立可复用的页面壳与分组卡片语义 +- 两个子页面共享同一结构语言与间距节奏 +- `修改密码` 采用步骤型结构(Step 1 验证邮箱 / Step 2 设置新密码) + +## 信息架构 + +### 编辑资料 + +- 页面头部:返回 + 标题 + 简短辅助说明 +- 主内容卡: + - 资料概览组(头像占位、当前账号信息) + - 基础信息组(用户名输入) + - 个人简介组(多行输入 + 字数) +- 底部强调操作区:`保存修改` 按钮(仅在有变更且可提交时可用) + +### 修改密码(步骤型) + +- 页面头部:返回 + 标题 + 简短辅助说明 +- Step 1:邮箱验证与验证码发送 + - 展示当前邮箱 + - 发送/重发验证码 + 倒计时 +- Step 2:输入验证码并设置新密码 + - 验证码输入 + - 新密码、确认密码 + - 状态提示(使用 `AppBanner`) +- 底部强调操作区:`确认修改` + +## 视觉与交互规则 + +- 背景:`AppColors.surfaceSecondary` +- 主卡:`AppColors.white` + `AppColors.borderSecondary` + 圆角 `AppRadius.lg/xl` +- 次级分组:`AppColors.surfaceTertiary/surfaceInfoLight`(按语义使用)+ `AppColors.borderTertiary` +- 输入聚焦态:`AppColors.blue500` +- 间距节奏: + - 页面外边距:`AppSpacing.xl` + - 组内:`AppSpacing.sm/lg` + - 组间:`AppSpacing.xxl` +- 动效策略:仅保留柔和状态反馈(按钮 loading/disabled、步骤区显隐、输入 focus),不添加噱头动画 + +## 组件与复用策略 + +- 优先复用: + - `AppButton` + - `FixedLengthCodeInput` + - `AppBanner` + - Toast 系统 + - `PasswordField`(与忘记密码页统一密码输入体验) +- 在 settings 域新增可复用 UI: + - `account_surface_scaffold.dart` + - `account_section_card.dart` + +## 影响文件 + +- 修改:`apps/lib/features/settings/ui/screens/edit_profile_screen.dart` +- 修改:`apps/lib/features/settings/ui/screens/change_password_screen.dart` +- 新增:`apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart` +- 新增:`apps/lib/features/settings/ui/widgets/account_section_card.dart` +- 可能补充:`apps/lib/core/theme/design_tokens.dart`(仅缺失 token 时) + +## 验收标准 + +- 两个页面具备一致的“柔和卡片分层”结构 +- 修改密码页面与忘记密码页面在设计语言上可感知一致 +- 不出现“纯白文档页/后台表单页”观感 +- 不新增视觉硬编码,遵守 token 体系 +- 核心交互逻辑不回归(验证码、倒计时、提交、错误提示) + +## 验证计划 + +- `flutter analyze` +- `flutter test`(至少覆盖 auth/reset-password 相关与 settings 相关用例) +- 手工验证: + - 编辑资料:无改动禁用、改动后可保存、成功后返回 + - 修改密码:发送验证码、倒计时、错误提示、成功返回 diff --git a/docs/plans/2026-03-16-settings-account-subpages-implementation.md b/docs/plans/2026-03-16-settings-account-subpages-implementation.md new file mode 100644 index 0000000..4827fd6 --- /dev/null +++ b/docs/plans/2026-03-16-settings-account-subpages-implementation.md @@ -0,0 +1,151 @@ +# 设置账户子页面视觉重构 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 重构设置页中的编辑资料与修改密码子页面,使其符合视觉设计语言并统一为柔和卡片分层风格。 + +**Architecture:** 在 settings feature 内新增复用型页面壳与分组卡组件,统一两页的表面层级、间距节奏与交互强调区;修改密码页面借鉴忘记密码页面的步骤分段与状态提示语义,但保留设置域信息架构与文案。保持现有业务逻辑不变,优先做样式与结构重构。 + +**Tech Stack:** Flutter, go_router, flutter_bloc, formz, design tokens (`AppColors/AppSpacing/AppRadius`), shared widgets (`AppButton`, `AppBanner`, `FixedLengthCodeInput`, Toast) + +--- + +### Task 1: 创建设置域复用 UI 壳层组件 + +**Files:** +- Create: `apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart` +- Create: `apps/lib/features/settings/ui/widgets/account_section_card.dart` + +**Step 1: 写一个失败的基础渲染测试(可选,若项目已有 widget test 基础)** + +```dart +testWidgets('AccountSectionCard renders title and child', (tester) async { + await tester.pumpWidget(...); + expect(find.text('标题'), findsOneWidget); +}); +``` + +**Step 2: 运行测试确认失败(若执行 Step 1)** + +Run: `flutter test apps/test/... -r expanded` +Expected: FAIL(新组件不存在) + +**Step 3: 实现最小组件** + +- `AccountSurfaceScaffold`:统一背景、头部、滚动主内容、可选底部强调区 +- `AccountSectionCard`:统一分组卡片容器(标题、描述、child) + +**Step 4: 运行测试确认通过(若执行 Step 1)** + +Run: `flutter test apps/test/... -r expanded` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart apps/lib/features/settings/ui/widgets/account_section_card.dart +git commit -m "feat: add reusable account surface widgets" +``` + +### Task 2: 重构编辑资料页面为柔和卡片分层 + +**Files:** +- Modify: `apps/lib/features/settings/ui/screens/edit_profile_screen.dart` + +**Step 1: 写失败测试(仅在当前测试基建允许时)** + +```dart +testWidgets('Edit profile shows grouped sections and disabled save initially', (tester) async { + // verify section titles and save button disabled by default +}); +``` + +**Step 2: 运行测试确认失败(若执行 Step 1)** + +Run: `flutter test apps/test/... -r expanded` +Expected: FAIL(旧结构不满足) + +**Step 3: 实现最小重构** + +- 接入 `AccountSurfaceScaffold` +- 将头像区、用户名区、简介区改为分组卡语义 +- 统一输入风格(token 化) +- 底部强调操作区承载 `保存修改` + +**Step 4: 运行测试确认通过(若执行 Step 1)** + +Run: `flutter test apps/test/... -r expanded` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/lib/features/settings/ui/screens/edit_profile_screen.dart +git commit -m "feat: redesign edit profile screen with layered surfaces" +``` + +### Task 3: 重构修改密码页面为步骤型结构 + +**Files:** +- Modify: `apps/lib/features/settings/ui/screens/change_password_screen.dart` +- Reference: `apps/lib/features/auth/ui/screens/reset_password_screen.dart` + +**Step 1: 写失败测试(仅在当前测试基建允许时)** + +```dart +testWidgets('Change password renders step sections and submit state', (tester) async { + // verify step titles and CTA disabled before code sent +}); +``` + +**Step 2: 运行测试确认失败(若执行 Step 1)** + +Run: `flutter test apps/test/... -r expanded` +Expected: FAIL(步骤结构不存在) + +**Step 3: 实现最小重构** + +- 接入 `AccountSurfaceScaffold` + `AccountSectionCard` +- 按 Step 1/Step 2 分段展示(邮箱验证 -> 验证码与新密码) +- 引入 `AppBanner` 做状态提示 +- 保持验证码、倒计时、提交逻辑不变 + +**Step 4: 运行测试确认通过(若执行 Step 1)** + +Run: `flutter test apps/test/... -r expanded` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/lib/features/settings/ui/screens/change_password_screen.dart +git commit -m "feat: redesign change password flow with step-based sections" +``` + +### Task 4: 验证与文档同步 + +**Files:** +- Modify (if needed): `docs/plans/2026-03-16-settings-account-subpages-design.md` + +**Step 1: 运行静态检查** + +Run: `flutter analyze` +Expected: PASS + +**Step 2: 运行回归测试** + +Run: `flutter test` +Expected: PASS + +**Step 3: 手工验收** + +- 编辑资料:无变更禁用、有变更可保存 +- 修改密码:发码、倒计时、错误提示、成功返回 +- 视觉层级:背景/主卡/分组/强调区清晰 + +**Step 4: Commit** + +```bash +git add docs/plans/2026-03-16-settings-account-subpages-design.md +git commit -m "docs: finalize account subpages redesign validation notes" +``` diff --git a/docs/protocols/agent/api-endpoints.md b/docs/protocols/agent/api-endpoints.md index 76ec581..6f212be 100644 --- a/docs/protocols/agent/api-endpoints.md +++ b/docs/protocols/agent/api-endpoints.md @@ -112,7 +112,10 @@ Base URL: `/api/v1/agent` seq: number; role: "user" | "assistant" | "tool"; content: string; - url?: string | null; // user 附件签名链接 + attachments?: Array<{ // user 附件签名链接列表 + mimeType: string; + url: string; + }>; ui_schema?: object | null; // assistant/tool 的编译后 UI timestamp: string; // ISO-8601 }>; diff --git a/docs/protocols/agent/run-agent-input.md b/docs/protocols/agent/run-agent-input.md index fed83d7..5942c6e 100644 --- a/docs/protocols/agent/run-agent-input.md +++ b/docs/protocols/agent/run-agent-input.md @@ -163,19 +163,14 @@ interface Tool { } ``` -### Backend Processing +### Frontend Tool Channel Semantics -Backend 使用 `build_tools_prompt` 函数将 tools 转换为 prompt 格式: +- `RunAgentInput.tools` 用于前端工具通道(供前端/HITL 审批链路透传与回放)。 +- 当前后端 AgentScope 执行阶段**不**使用该字段来注册或筛选后端工具。 +- 后端可调用工具以 `toolkit.register_tool_function(...)` 注册结果为准(例如 `calendar_write`)。 +- 因此 `tools` 可为空数组,不影响后端已注册工具的调用能力。 -``` - -- get_weather: Get current weather for a location - - args_schema: {"type":"object","properties":{"location":{"type":"string","description":"City name"},"unit":{"type":"string","enum":["celsius","fahrenheit"]}},"required":["location"]} -- searchDocuments: Search for documents - - args_schema: {"type":"object","properties":{"query":{"type":"string"}},"required":["query"]} -Note: tool arguments must strictly match args_schema. - -``` +> 注意:请勿把后端已注册工具重复写入 `RunAgentInput.tools`,避免命名/参数描述冲突。 --- @@ -206,6 +201,7 @@ Backend 实现了以下验证规则: | binary 必须是 image/* | `binary content requires image mimeType` | | binary 必须有 url | `binary content requires url` | | binary 不允许使用 data | `binary content data is not allowed` | +| 单条消息最多 3 张附件 | `Too many attachments` | --- @@ -349,10 +345,15 @@ interface HistoryMessageUser { seq: number; role: "user"; content: string; - url: string | null; // 附件临时访问 URL + attachments: HistoryAttachment[]; timestamp: string; // ISO-8601 timestamp } +interface HistoryAttachment { + mimeType: string; + url: string; +} + // role = "tool" interface HistoryMessageTool { id: string; @@ -410,7 +411,12 @@ interface UiSchemaRenderer { "seq": 1, "role": "user", "content": "帮我创建一个日程", - "url": null, + "attachments": [ + { + "mimeType": "image/png", + "url": "https://project.supabase.co/storage/v1/object/sign/agent-inputs/..." + } + ], "timestamp": "2026-03-15T10:00:00Z" }, { @@ -446,5 +452,5 @@ interface UiSchemaRenderer { - `UserMessage.content` 支持 string 或 array 格式,前端优先使用 array 格式以支持多模态 - binary content 的 url 必须是有效的 signed URL,由 `/api/v1/agent/attachments` 端点生成 - backend 验证通过后,会将 binary url 转换为内部存储路径 -- tools 为空数组时,prompt 中不会包含工具说明 +- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt - `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)