feat: 优化 Agent 运行时与聊天设置体验
This commit is contained in:
@@ -308,7 +308,7 @@ class HistoryMessage {
|
||||
required this.role,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
this.url,
|
||||
this.attachments = const <HistoryAttachment>[],
|
||||
this.uiSchema,
|
||||
});
|
||||
|
||||
@@ -317,7 +317,7 @@ class HistoryMessage {
|
||||
final String role;
|
||||
final String content;
|
||||
final DateTime timestamp;
|
||||
final String? url;
|
||||
final List<HistoryAttachment> attachments;
|
||||
final Map<String, dynamic>? uiSchema;
|
||||
|
||||
factory HistoryMessage.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>? _asMap(Object? value) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<HistoryAttachment> _parseHistoryAttachments(Object? value) {
|
||||
if (value is! List) {
|
||||
return const <HistoryAttachment>[];
|
||||
}
|
||||
return value
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(HistoryAttachment.fromJson)
|
||||
.where(
|
||||
(attachment) =>
|
||||
attachment.url.isNotEmpty && attachment.mimeType.isNotEmpty,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -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 => '任务处理中',
|
||||
};
|
||||
}
|
||||
@@ -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<ChatListItem> items;
|
||||
@@ -93,6 +92,19 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
<String, Future<Uint8List?>>{};
|
||||
|
||||
/// 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<ChatState> {
|
||||
),
|
||||
);
|
||||
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<ChatState> {
|
||||
}
|
||||
|
||||
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<ChatListItem>.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<ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
List<ChatListItem> _updateOrAddMessage(
|
||||
List<ChatListItem> items,
|
||||
String messageId,
|
||||
String content,
|
||||
DateTime timestamp,
|
||||
) {
|
||||
final result = List<ChatListItem>.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<ChatListItem> items,
|
||||
String messageId,
|
||||
Map<String, dynamic> 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<ChatListItem>.from(state.items)
|
||||
..add(
|
||||
@@ -299,10 +312,14 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
final converted = <ChatListItem>[];
|
||||
for (final msg in messages) {
|
||||
final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai;
|
||||
final attachments = <Map<String, dynamic>>[];
|
||||
if (msg.url != null && msg.url!.isNotEmpty) {
|
||||
attachments.add({'url': msg.url!, 'mimeType': 'image/*'});
|
||||
}
|
||||
final attachments = msg.attachments
|
||||
.map(
|
||||
(attachment) => <String, dynamic>{
|
||||
'url': attachment.url,
|
||||
'mimeType': attachment.mimeType,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (msg.content.isNotEmpty || sender == MessageSender.user) {
|
||||
converted.add(
|
||||
@@ -500,16 +517,3 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +219,17 @@ class UiSchemaRenderer {
|
||||
fallback: _asString(item['key']),
|
||||
);
|
||||
final value = item['value']?.toString() ?? '-';
|
||||
return Row(
|
||||
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(
|
||||
@@ -232,11 +250,12 @@ class UiSchemaRenderer {
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.slate800,
|
||||
fontWeight: FontWeight.w500,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> Function(String filePath)? onTranscribeAudio;
|
||||
@@ -258,12 +267,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
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<HomeScreen>
|
||||
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<HomeScreen>
|
||||
};
|
||||
|
||||
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),
|
||||
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),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.toolName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate700,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -819,16 +847,17 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
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: 24),
|
||||
_buildLogoutButton(context),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSecurityCard(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 64,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
Widget _buildProfileHero(BuildContext context) {
|
||||
final authState = context.watch<AuthBloc>().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: [
|
||||
widgets.BackButton(),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'我的账户',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<AuthBloc>().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<void> _handleSubmit() async {
|
||||
final email = _resolveUserEmail();
|
||||
if (email.isEmpty) {
|
||||
Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
final cubit = context.read<ResetPasswordCubit>();
|
||||
cubit.emailChanged(_userEmail);
|
||||
cubit.emailChanged(email);
|
||||
cubit.codeChanged(_codeController.text);
|
||||
cubit.newPasswordChanged(_passwordController.text);
|
||||
cubit.confirmPasswordChanged(_confirmPasswordController.text);
|
||||
@@ -94,74 +96,17 @@ 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,
|
||||
child: AccountSurfaceScaffold(
|
||||
title: '修改密码',
|
||||
subtitle: '通过邮箱验证码安全更新你的登录密码',
|
||||
onBack: () => context.pop(),
|
||||
body: _buildForm(),
|
||||
footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
|
||||
builder: (context, state) {
|
||||
return _buildSubmitButton(state);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildEmailDisplay(),
|
||||
const SizedBox(height: 24),
|
||||
_buildForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,38 +116,136 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
|
||||
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(
|
||||
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: 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<ResetPasswordCubit>().resendCode();
|
||||
} else {
|
||||
context.read<ResetPasswordCubit>().emailChanged(
|
||||
userEmail,
|
||||
);
|
||||
context.read<ResetPasswordCubit>().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,
|
||||
),
|
||||
if (state.codeSent)
|
||||
AppBanner(
|
||||
title: '验证码已发送',
|
||||
message: state.resendCountdown > 0
|
||||
? '如未收到,可在 ${state.resendCountdown} 秒后重新发送。'
|
||||
: '若未收到邮件,可重新发送验证码。',
|
||||
type: ToastType.info,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
const Text(
|
||||
'验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FixedLengthCodeInput(
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
FixedLengthCodeInput(
|
||||
controller: _codeController,
|
||||
length: 6,
|
||||
semanticLabel: '修改密码验证码输入框',
|
||||
@@ -223,145 +266,128 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
|
||||
context.read<ResetPasswordCubit>().codeChanged(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
state.resendCountdown > 0 ||
|
||||
state.status == FormzSubmissionStatus.inProgress
|
||||
? null
|
||||
: () {
|
||||
if (state.codeSent) {
|
||||
context.read<ResetPasswordCubit>().resendCode();
|
||||
} else {
|
||||
context.read<ResetPasswordCubit>().emailChanged(
|
||||
_userEmail,
|
||||
);
|
||||
context.read<ResetPasswordCubit>().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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
return _buildPasswordField(
|
||||
label: '新密码',
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
onChanged: (value) {
|
||||
context.read<ResetPasswordCubit>().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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
hasError: hasError,
|
||||
isObscured: _obscurePassword,
|
||||
onToggleVisibility: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
onChanged: (value) =>
|
||||
context.read<ResetPasswordCubit>().newPasswordChanged(value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmPasswordInput(bool hasError) {
|
||||
return _buildPasswordField(
|
||||
label: '确认密码',
|
||||
controller: _confirmPasswordController,
|
||||
hintText: '请再次输入新密码',
|
||||
hasError: hasError,
|
||||
isObscured: _obscureConfirmPassword,
|
||||
onToggleVisibility: () =>
|
||||
setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||
onChanged: (value) =>
|
||||
context.read<ResetPasswordCubit>().confirmPasswordChanged(value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPasswordField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required bool hasError,
|
||||
required bool isObscured,
|
||||
required VoidCallback onToggleVisibility,
|
||||
required ValueChanged<String> 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<ResetPasswordCubit>().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(
|
||||
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(
|
||||
@@ -369,6 +395,8 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> {
|
||||
onPressed: isDisabled ? null : _handleSubmit,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EditProfileScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surfaceSecondary,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
return AccountSurfaceScaffold(
|
||||
title: '编辑资料',
|
||||
subtitle: '完善公开信息,让好友更容易认识你',
|
||||
onBack: () => context.pop(),
|
||||
body: _isLoading
|
||||
? const Center(
|
||||
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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(
|
||||
_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,
|
||||
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: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFEAF1FF), Color(0xFFF8FBFF)],
|
||||
colors: [AppColors.blue100, AppColors.surfaceInfoLight],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(36),
|
||||
border: Border.all(color: const Color(0xFFD9E5FA)),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderQuaternary),
|
||||
),
|
||||
child: const Icon(Icons.person, size: 36, color: AppColors.blue500),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 28,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'点击更换头像',
|
||||
style: TextStyle(
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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), '任务处理中');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<AuthRepository>(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<void> pumpScreen(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<AuthBloc>.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<ElevatedButton>(
|
||||
find.widgetWithText(ElevatedButton, '确认修改'),
|
||||
);
|
||||
expect(confirmButton.onPressed, isNull);
|
||||
expect(find.text('完成验证码验证后可提交密码修改'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async {
|
||||
final completer = Completer<void>();
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]",
|
||||
|
||||
@@ -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=[
|
||||
_, payload = await finalize_json_response(
|
||||
model=self.model,
|
||||
formatter=self.formatter,
|
||||
base_messages=[
|
||||
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",
|
||||
),
|
||||
],
|
||||
output_model=output_model,
|
||||
retries=self._finalize_retries,
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,15 +70,54 @@ 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_output = await self._execute_router_step(
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
run_input=run_input,
|
||||
user_context=user_context,
|
||||
context_messages=context_messages,
|
||||
stage_config=router_config,
|
||||
)
|
||||
worker_output = await self._execute_worker_step(
|
||||
pipeline=pipeline,
|
||||
run_input=run_input,
|
||||
user_context=user_context,
|
||||
router_output=router_output,
|
||||
toolkit=worker_toolkit,
|
||||
stage_config=worker_config,
|
||||
)
|
||||
|
||||
return {
|
||||
"router": router_output.model_dump(mode="json", exclude_none=True),
|
||||
"worker": worker_output.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
|
||||
def _build_worker_toolkit(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
owner_id: UUID,
|
||||
) -> Any:
|
||||
return build_stage_toolkit(
|
||||
agent_type=AgentType.WORKER,
|
||||
session=session,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -327,7 +126,18 @@ class AgentScopeRunner:
|
||||
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,
|
||||
@@ -337,16 +147,15 @@ class AgentScopeRunner:
|
||||
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,
|
||||
stage_config=stage_config,
|
||||
)
|
||||
router_output = RouterAgentOutput.model_validate(router_result.payload)
|
||||
await self._persist_router_message(
|
||||
await persist_router_message(
|
||||
session=session,
|
||||
thread_id=run_input.thread_id,
|
||||
run_id=run_input.run_id,
|
||||
model_code=router_config.model_code,
|
||||
model_code=stage_config.model_code,
|
||||
router_output=router_output,
|
||||
response_metadata=router_result.response_metadata,
|
||||
)
|
||||
@@ -357,7 +166,18 @@ class AgentScopeRunner:
|
||||
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,
|
||||
@@ -368,9 +188,9 @@ class AgentScopeRunner:
|
||||
worker_result = await self._run_worker_stage(
|
||||
user_context=user_context,
|
||||
router_output=router_output,
|
||||
toolkit=worker_toolkit,
|
||||
toolkit=toolkit,
|
||||
run_input=run_input,
|
||||
stage_config=worker_config,
|
||||
stage_config=stage_config,
|
||||
worker_output_model=worker_output_model,
|
||||
pipeline=pipeline,
|
||||
)
|
||||
@@ -381,46 +201,7 @@ class AgentScopeRunner:
|
||||
step_name="worker",
|
||||
event_type="STEP_FINISHED",
|
||||
)
|
||||
|
||||
return {
|
||||
"router": router_output.model_dump(mode="json", exclude_none=True),
|
||||
"worker": worker_output.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
|
||||
def _build_toolkits(
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
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(
|
||||
return [
|
||||
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}"
|
||||
),
|
||||
content=build_worker_contract_prompt(router_output=router_output),
|
||||
)
|
||||
return [routing_msg]
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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:
|
||||
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=bucket,
|
||||
path=path,
|
||||
bucket=attachment.bucket,
|
||||
path=attachment.path,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
b64_data = base64.b64encode(image_bytes).decode("utf-8")
|
||||
converted.append(
|
||||
Msg(
|
||||
name="user",
|
||||
role="user",
|
||||
content=[
|
||||
{"type": "text", "text": content},
|
||||
image_blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mime_type or "image/png",
|
||||
"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
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if role == "tool":
|
||||
role = "assistant"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
)
|
||||
-14
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
if not attachments or not get_signed_url_fn:
|
||||
return None
|
||||
return []
|
||||
|
||||
signed_attachments: list[dict[str, str]] = []
|
||||
for attachment in resolved:
|
||||
try:
|
||||
return get_signed_url_fn(
|
||||
{"bucket": attachments.bucket, "path": attachments.path}
|
||||
signed_url = get_signed_url_fn(
|
||||
{"bucket": attachment.bucket, "path": attachment.path}
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
continue
|
||||
signed_attachments.append(
|
||||
{
|
||||
"url": signed_url,
|
||||
"mimeType": attachment.mime_type,
|
||||
}
|
||||
)
|
||||
return signed_attachments
|
||||
|
||||
|
||||
def _compile_tool_ui_hints(
|
||||
|
||||
@@ -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():
|
||||
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 = parsed
|
||||
candidates.append((version, build, f.name))
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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 相关用例)
|
||||
- 手工验证:
|
||||
- 编辑资料:无改动禁用、改动后可保存、成功后返回
|
||||
- 修改密码:发送验证码、倒计时、错误提示、成功返回
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
|
||||
}>;
|
||||
|
||||
@@ -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` 可为空数组,不影响后端已注册工具的调用能力。
|
||||
|
||||
```
|
||||
<!-- TOOLS_START -->
|
||||
- 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.
|
||||
<!-- TOOLS_END -->
|
||||
```
|
||||
> 注意:请勿把后端已注册工具重复写入 `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)
|
||||
|
||||
Reference in New Issue
Block a user