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,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,
};
}