feat: 优化 Agent 运行时与聊天设置体验
This commit is contained in:
@@ -308,7 +308,7 @@ class HistoryMessage {
|
||||
required this.role,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
this.url,
|
||||
this.attachments = const <HistoryAttachment>[],
|
||||
this.uiSchema,
|
||||
});
|
||||
|
||||
@@ -317,7 +317,7 @@ class HistoryMessage {
|
||||
final String role;
|
||||
final String content;
|
||||
final DateTime timestamp;
|
||||
final String? url;
|
||||
final List<HistoryAttachment> attachments;
|
||||
final Map<String, dynamic>? uiSchema;
|
||||
|
||||
factory HistoryMessage.fromJson(Map<String, dynamic> json) => HistoryMessage(
|
||||
@@ -327,11 +327,25 @@ class HistoryMessage {
|
||||
content: _asString(json['content']),
|
||||
timestamp:
|
||||
DateTime.tryParse(_asString(json['timestamp'])) ?? DateTime.now(),
|
||||
url: json['url'] as String?,
|
||||
attachments: _parseHistoryAttachments(json['attachments']),
|
||||
uiSchema: _asMap(json['ui_schema']) ?? _asMap(json['uiSchema']),
|
||||
);
|
||||
}
|
||||
|
||||
class HistoryAttachment {
|
||||
const HistoryAttachment({required this.url, required this.mimeType});
|
||||
|
||||
final String url;
|
||||
final String mimeType;
|
||||
|
||||
factory HistoryAttachment.fromJson(Map<String, dynamic> json) {
|
||||
return HistoryAttachment(
|
||||
url: _asString(json['url']),
|
||||
mimeType: _asString(json['mimeType']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _asString(Object? value, {String fallback = ''}) {
|
||||
if (value is String) {
|
||||
return value;
|
||||
@@ -368,3 +382,17 @@ Map<String, dynamic>? _asMap(Object? value) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<HistoryAttachment> _parseHistoryAttachments(Object? value) {
|
||||
if (value is! List) {
|
||||
return const <HistoryAttachment>[];
|
||||
}
|
||||
return value
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(HistoryAttachment.fromJson)
|
||||
.where(
|
||||
(attachment) =>
|
||||
attachment.url.isNotEmpty && attachment.mimeType.isNotEmpty,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
enum AgentStage { intent, execution }
|
||||
|
||||
AgentStage? stageFromStepName(String value) {
|
||||
switch (value) {
|
||||
case 'router':
|
||||
return AgentStage.intent;
|
||||
case 'worker':
|
||||
return AgentStage.execution;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String stageLabel(AgentStage? stage) {
|
||||
return switch (stage) {
|
||||
AgentStage.intent => '意图识别中',
|
||||
AgentStage.execution => '任务执行中',
|
||||
null => '任务处理中',
|
||||
};
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import 'package:social_app/core/api/i_api_client.dart';
|
||||
import '../../data/models/ag_ui_event.dart';
|
||||
import '../../data/models/chat_list_item.dart';
|
||||
import '../../data/services/ag_ui_service.dart';
|
||||
|
||||
enum AgentStage { intent, execution, report }
|
||||
import 'agent_stage.dart';
|
||||
|
||||
class ChatState {
|
||||
final List<ChatListItem> items;
|
||||
@@ -93,6 +92,19 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
final Map<String, Future<Uint8List?>> _attachmentPreviewInflight =
|
||||
<String, Future<Uint8List?>>{};
|
||||
|
||||
/// Common state reset for run completion (success/error/cancel)
|
||||
ChatState _resetRunState({String? error, String? currentMessageId}) {
|
||||
return state.copyWith(
|
||||
isSending: false,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
currentMessageId: currentMessageId,
|
||||
error: error,
|
||||
currentStage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEvent(AgUiEvent event) {
|
||||
switch (event.type) {
|
||||
case AgUiEventType.runStarted:
|
||||
@@ -106,29 +118,10 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
),
|
||||
);
|
||||
case AgUiEventType.runFinished:
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSending: false,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
currentMessageId: null,
|
||||
currentStage: null,
|
||||
),
|
||||
);
|
||||
emit(_resetRunState());
|
||||
case AgUiEventType.runError:
|
||||
final errorEvent = event as RunErrorEvent;
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSending: false,
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
currentMessageId: null,
|
||||
error: errorEvent.message,
|
||||
currentStage: null,
|
||||
),
|
||||
);
|
||||
emit(_resetRunState(error: errorEvent.message));
|
||||
case AgUiEventType.stepStarted:
|
||||
_handleStepStarted(event as StepStartedEvent);
|
||||
case AgUiEventType.stepFinished:
|
||||
@@ -151,57 +144,27 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
|
||||
void _handleStepStarted(StepStartedEvent event) {
|
||||
emit(state.copyWith(currentStage: _stageFromName(event.stepName)));
|
||||
emit(state.copyWith(currentStage: stageFromStepName(event.stepName)));
|
||||
}
|
||||
|
||||
void _handleStepFinished(StepFinishedEvent event) {
|
||||
if (state.currentStage == _stageFromName(event.stepName)) {
|
||||
if (state.currentStage == stageFromStepName(event.stepName)) {
|
||||
emit(state.copyWith(currentStage: null));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextMessageEnd(TextMessageEndEvent event) {
|
||||
final timestamp = DateTime.now();
|
||||
final items = List<ChatListItem>.from(state.items);
|
||||
|
||||
final messageIndex = items.indexWhere(
|
||||
(item) => item.id == event.messageId && item is TextMessageItem,
|
||||
final items = _updateOrAddMessage(
|
||||
state.items,
|
||||
event.messageId,
|
||||
event.answer,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
if (messageIndex >= 0) {
|
||||
final existing = items[messageIndex] as TextMessageItem;
|
||||
items[messageIndex] = existing.copyWith(
|
||||
content: event.answer,
|
||||
isStreaming: false,
|
||||
);
|
||||
} else {
|
||||
items.add(
|
||||
TextMessageItem(
|
||||
id: event.messageId,
|
||||
content: event.answer,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final uiSchema = event.uiSchema;
|
||||
if (uiSchema != null) {
|
||||
final uiItemId = '${event.messageId}-ui';
|
||||
final existingUiIndex = items.indexWhere((item) => item.id == uiItemId);
|
||||
final uiItem = ToolResultItem(
|
||||
id: uiItemId,
|
||||
callId: event.messageId,
|
||||
uiSchema: uiSchema,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
if (existingUiIndex >= 0) {
|
||||
items[existingUiIndex] = uiItem;
|
||||
} else {
|
||||
items.add(uiItem);
|
||||
}
|
||||
_upsertUiSchema(items, event.messageId, uiSchema, timestamp);
|
||||
}
|
||||
|
||||
emit(
|
||||
@@ -214,6 +177,56 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
List<ChatListItem> _updateOrAddMessage(
|
||||
List<ChatListItem> items,
|
||||
String messageId,
|
||||
String content,
|
||||
DateTime timestamp,
|
||||
) {
|
||||
final result = List<ChatListItem>.from(items);
|
||||
final index = result.indexWhere(
|
||||
(item) => item.id == messageId && item is TextMessageItem,
|
||||
);
|
||||
|
||||
if (index >= 0) {
|
||||
final existing = result[index] as TextMessageItem;
|
||||
result[index] = existing.copyWith(content: content, isStreaming: false);
|
||||
} else {
|
||||
result.add(
|
||||
TextMessageItem(
|
||||
id: messageId,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void _upsertUiSchema(
|
||||
List<ChatListItem> items,
|
||||
String messageId,
|
||||
Map<String, dynamic> uiSchema,
|
||||
DateTime timestamp,
|
||||
) {
|
||||
final uiItemId = '$messageId-ui';
|
||||
final existingIndex = items.indexWhere((item) => item.id == uiItemId);
|
||||
final uiItem = ToolResultItem(
|
||||
id: uiItemId,
|
||||
callId: messageId,
|
||||
uiSchema: uiSchema,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
items[existingIndex] = uiItem;
|
||||
} else {
|
||||
items.add(uiItem);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleToolCallStart(ToolCallStartEvent event) {
|
||||
final items = List<ChatListItem>.from(state.items)
|
||||
..add(
|
||||
@@ -299,10 +312,14 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
final converted = <ChatListItem>[];
|
||||
for (final msg in messages) {
|
||||
final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai;
|
||||
final attachments = <Map<String, dynamic>>[];
|
||||
if (msg.url != null && msg.url!.isNotEmpty) {
|
||||
attachments.add({'url': msg.url!, 'mimeType': 'image/*'});
|
||||
}
|
||||
final attachments = msg.attachments
|
||||
.map(
|
||||
(attachment) => <String, dynamic>{
|
||||
'url': attachment.url,
|
||||
'mimeType': attachment.mimeType,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (msg.content.isNotEmpty || sender == MessageSender.user) {
|
||||
converted.add(
|
||||
@@ -500,16 +517,3 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
emit(state.copyWith(error: null));
|
||||
}
|
||||
}
|
||||
|
||||
AgentStage? _stageFromName(String value) {
|
||||
switch (value) {
|
||||
case 'intent':
|
||||
return AgentStage.intent;
|
||||
case 'execution':
|
||||
return AgentStage.execution;
|
||||
case 'report':
|
||||
return AgentStage.report;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,24 +94,28 @@ class UiSchemaRenderer {
|
||||
final status = _asString(node['status']);
|
||||
final style = switch (role) {
|
||||
'title' => const TextStyle(
|
||||
fontSize: 17,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate900,
|
||||
height: 1.25,
|
||||
height: 1.2,
|
||||
),
|
||||
'subtitle' => const TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
'caption' => const TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
'caption' => const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
height: 1.4,
|
||||
),
|
||||
'code' => const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate700,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
_ => const TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 15,
|
||||
color: AppColors.slate700,
|
||||
height: 1.45,
|
||||
),
|
||||
@@ -139,16 +143,17 @@ class UiSchemaRenderer {
|
||||
final bg = _statusBackground(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: _statusBorder(status)),
|
||||
),
|
||||
child: Text(
|
||||
_asString(node['label']),
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: fg),
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fg),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -173,17 +178,20 @@ class UiSchemaRenderer {
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
backgroundColor: style == 'primary'
|
||||
? AppColors.blue600
|
||||
: AppColors.homeComposerAccent,
|
||||
? AppColors.authPrimaryButton
|
||||
: AppColors.surfaceInfoLight,
|
||||
foregroundColor: style == 'primary'
|
||||
? AppColors.white
|
||||
: AppColors.slate700,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
side: style == 'primary'
|
||||
? BorderSide.none
|
||||
: const BorderSide(color: AppColors.borderTertiary),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
@@ -211,32 +219,43 @@ class UiSchemaRenderer {
|
||||
fallback: _asString(item['key']),
|
||||
);
|
||||
final value = item['value']?.toString() ?? '-';
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.slate800,
|
||||
fontWeight: FontWeight.w500,
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.slate800,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
AppSpacing.xs,
|
||||
@@ -259,7 +278,8 @@ class UiSchemaRenderer {
|
||||
return child;
|
||||
}
|
||||
final bg = switch (appearance) {
|
||||
'section' => AppColors.homeComposerInner,
|
||||
'section' => AppColors.surfaceSecondary,
|
||||
'card' => AppColors.white,
|
||||
_ => _statusBackground(status),
|
||||
};
|
||||
final borderColor = switch (status) {
|
||||
@@ -270,16 +290,16 @@ class UiSchemaRenderer {
|
||||
};
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: borderColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.blue100.withValues(alpha: 0.35),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
color: AppColors.slate200.withValues(alpha: 0.6),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -325,7 +345,17 @@ class UiSchemaRenderer {
|
||||
'warning' => AppColors.feedbackWarningSurface,
|
||||
'error' => AppColors.feedbackErrorSurface,
|
||||
'pending' => AppColors.feedbackInfoSurface,
|
||||
_ => AppColors.homeConversationSurface,
|
||||
_ => AppColors.surfaceSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
static Color _statusBorder(String status) {
|
||||
return switch (status) {
|
||||
'success' => AppColors.feedbackSuccessBorder,
|
||||
'warning' => AppColors.feedbackWarningBorder,
|
||||
'error' => AppColors.feedbackErrorBorder,
|
||||
'pending' => AppColors.feedbackInfoBorder,
|
||||
_ => AppColors.borderTertiary,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user