feat: 优化 Agent 运行时与聊天设置体验

This commit is contained in:
qzl
2026-03-16 18:32:09 +08:00
parent 3f79cf0df7
commit 5a34616287
41 changed files with 2603 additions and 1263 deletions
@@ -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()
+106 -399
View File
@@ -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,
},
)
+27 -20
View File
@@ -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}"
)
@@ -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:
+32 -3
View File
@@ -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
+10 -3
View File
@@ -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,
+35 -15
View File
@@ -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(
+26 -21
View File
@@ -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(
+19 -7
View File
@@ -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
+64 -10
View File
@@ -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"
+23
View File
@@ -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"
```
+4 -1
View File
@@ -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
}>;
+20 -14
View File
@@ -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)