fix(chat): fix ChatBloc event callback and test reliability
- Fix onEvent callback initialization in ChatBloc constructor - Add MockAgUiService to isolate test from mock API behavior - Remove unnecessary non-null assertions in tests
This commit is contained in:
@@ -2,17 +2,23 @@ import 'dart:convert';
|
||||
|
||||
enum Intent { createEvent, searchEvent, unknown }
|
||||
|
||||
class AiDecisionEngine {
|
||||
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent)
|
||||
static final List<(RegExp, Intent)> _orderedPatterns = [
|
||||
(RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent),
|
||||
(RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent),
|
||||
(
|
||||
RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'),
|
||||
Intent.createEvent,
|
||||
),
|
||||
];
|
||||
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent)
|
||||
final _orderedPatterns = <(RegExp, Intent)>[
|
||||
(RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent),
|
||||
(RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent),
|
||||
(RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent),
|
||||
];
|
||||
|
||||
/// 时区常量
|
||||
const _defaultTimezone = 'Asia/Shanghai';
|
||||
const _dayToday = '今天';
|
||||
const _dayTomorrow = '明天';
|
||||
const _dayAfterTomorrow = '后天';
|
||||
const _tomorrowOffset = 1;
|
||||
const _dayAfterTomorrowOffset = 2;
|
||||
const _defaultMinute = 0;
|
||||
|
||||
class AiDecisionEngine {
|
||||
Intent matchIntent(String text) {
|
||||
for (final (pattern, intent) in _orderedPatterns) {
|
||||
if (pattern.hasMatch(text)) return intent;
|
||||
@@ -34,31 +40,33 @@ class AiDecisionEngine {
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (args['title'] == null || (args['title'] as String).isEmpty) return null;
|
||||
final title = args['title'];
|
||||
if (title == null || (title as String).isEmpty) return null;
|
||||
|
||||
final timeMatch = RegExp(
|
||||
r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?',
|
||||
).firstMatch(text);
|
||||
if (timeMatch != null) {
|
||||
final dayStr = timeMatch.group(1) ?? '今天';
|
||||
final dayStr = timeMatch.group(1) ?? _dayToday;
|
||||
final hour = int.parse(timeMatch.group(2)!);
|
||||
final minute = int.parse(timeMatch.group(3) ?? '0');
|
||||
final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute');
|
||||
|
||||
final now = DateTime.now();
|
||||
DateTime startAt;
|
||||
switch (dayStr) {
|
||||
case '明天':
|
||||
startAt = DateTime(now.year, now.month, now.day + 1, hour, minute);
|
||||
break;
|
||||
case '后天':
|
||||
startAt = DateTime(now.year, now.month, now.day + 2, hour, minute);
|
||||
break;
|
||||
default:
|
||||
startAt = DateTime(now.year, now.month, now.day, hour, minute);
|
||||
}
|
||||
final dayOffset = switch (dayStr) {
|
||||
_dayTomorrow => _tomorrowOffset,
|
||||
_dayAfterTomorrow => _dayAfterTomorrowOffset,
|
||||
_ => 0,
|
||||
};
|
||||
final startAt = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day + dayOffset,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
|
||||
args['startAt'] = startAt.toIso8601String();
|
||||
args['timezone'] = 'Asia/Shanghai';
|
||||
args['timezone'] = _defaultTimezone;
|
||||
}
|
||||
|
||||
if (!args.containsKey('startAt')) return null;
|
||||
|
||||
@@ -32,63 +32,39 @@ enum AgUiEventType {
|
||||
unknown,
|
||||
}
|
||||
|
||||
AgUiEventType agUiEventTypeFromWire(String wire) {
|
||||
switch (wire) {
|
||||
case AgUiEventTypeWire.runStarted:
|
||||
return AgUiEventType.runStarted;
|
||||
case AgUiEventTypeWire.runFinished:
|
||||
return AgUiEventType.runFinished;
|
||||
case AgUiEventTypeWire.runError:
|
||||
return AgUiEventType.runError;
|
||||
case AgUiEventTypeWire.textMessageStart:
|
||||
return AgUiEventType.textMessageStart;
|
||||
case AgUiEventTypeWire.textMessageContent:
|
||||
return AgUiEventType.textMessageContent;
|
||||
case AgUiEventTypeWire.textMessageEnd:
|
||||
return AgUiEventType.textMessageEnd;
|
||||
case AgUiEventTypeWire.toolCallStart:
|
||||
return AgUiEventType.toolCallStart;
|
||||
case AgUiEventTypeWire.toolCallArgs:
|
||||
return AgUiEventType.toolCallArgs;
|
||||
case AgUiEventTypeWire.toolCallEnd:
|
||||
return AgUiEventType.toolCallEnd;
|
||||
case AgUiEventTypeWire.toolCallResult:
|
||||
return AgUiEventType.toolCallResult;
|
||||
case AgUiEventTypeWire.toolCallError:
|
||||
return AgUiEventType.toolCallError;
|
||||
default:
|
||||
return AgUiEventType.unknown;
|
||||
}
|
||||
}
|
||||
const _wireToTypeMap = {
|
||||
AgUiEventTypeWire.runStarted: AgUiEventType.runStarted,
|
||||
AgUiEventTypeWire.runFinished: AgUiEventType.runFinished,
|
||||
AgUiEventTypeWire.runError: AgUiEventType.runError,
|
||||
AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart,
|
||||
AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent,
|
||||
AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd,
|
||||
AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart,
|
||||
AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs,
|
||||
AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd,
|
||||
AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult,
|
||||
AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError,
|
||||
};
|
||||
|
||||
String agUiEventTypeToWire(AgUiEventType type) {
|
||||
switch (type) {
|
||||
case AgUiEventType.runStarted:
|
||||
return AgUiEventTypeWire.runStarted;
|
||||
case AgUiEventType.runFinished:
|
||||
return AgUiEventTypeWire.runFinished;
|
||||
case AgUiEventType.runError:
|
||||
return AgUiEventTypeWire.runError;
|
||||
case AgUiEventType.textMessageStart:
|
||||
return AgUiEventTypeWire.textMessageStart;
|
||||
case AgUiEventType.textMessageContent:
|
||||
return AgUiEventTypeWire.textMessageContent;
|
||||
case AgUiEventType.textMessageEnd:
|
||||
return AgUiEventTypeWire.textMessageEnd;
|
||||
case AgUiEventType.toolCallStart:
|
||||
return AgUiEventTypeWire.toolCallStart;
|
||||
case AgUiEventType.toolCallArgs:
|
||||
return AgUiEventTypeWire.toolCallArgs;
|
||||
case AgUiEventType.toolCallEnd:
|
||||
return AgUiEventTypeWire.toolCallEnd;
|
||||
case AgUiEventType.toolCallResult:
|
||||
return AgUiEventTypeWire.toolCallResult;
|
||||
case AgUiEventType.toolCallError:
|
||||
return AgUiEventTypeWire.toolCallError;
|
||||
case AgUiEventType.unknown:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
const _typeToWireMap = {
|
||||
AgUiEventType.runStarted: AgUiEventTypeWire.runStarted,
|
||||
AgUiEventType.runFinished: AgUiEventTypeWire.runFinished,
|
||||
AgUiEventType.runError: AgUiEventTypeWire.runError,
|
||||
AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart,
|
||||
AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent,
|
||||
AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd,
|
||||
AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart,
|
||||
AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs,
|
||||
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
|
||||
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
|
||||
AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError,
|
||||
AgUiEventType.unknown: '',
|
||||
};
|
||||
|
||||
AgUiEventType agUiEventTypeFromWire(String wire) =>
|
||||
_wireToTypeMap[wire] ?? AgUiEventType.unknown;
|
||||
|
||||
String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? '';
|
||||
|
||||
@JsonSerializable()
|
||||
class AgUiEvent {
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'tool_result.g.dart';
|
||||
|
||||
/// Schema 版本常量
|
||||
const _defaultSchemaVersion = 'v1';
|
||||
|
||||
/// 工具执行结果(给 AI 的原始数据)
|
||||
@JsonSerializable()
|
||||
class ToolResult {
|
||||
@@ -31,7 +34,7 @@ class UiCard {
|
||||
|
||||
UiCard({
|
||||
required this.cardType,
|
||||
this.schemaVersion = 'v1',
|
||||
this.schemaVersion = _defaultSchemaVersion,
|
||||
required this.data,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,18 @@ import '../models/ag_ui_event.dart';
|
||||
import '../models/tool_result.dart';
|
||||
import '../tools/tool_registry.dart';
|
||||
|
||||
/// Mock ID 前缀常量
|
||||
const _threadIdPrefix = 'thread_';
|
||||
const _runIdPrefix = 'run_';
|
||||
const _toolCallIdPrefix = 'tc_';
|
||||
const _messageIdPrefix = 'msg_';
|
||||
|
||||
/// 流式输出延迟 (毫秒)
|
||||
const _streamChunkDelayMs = 50;
|
||||
|
||||
/// 文本块大小
|
||||
const _textChunkSize = 10;
|
||||
|
||||
typedef EventCallback = void Function(AgUiEvent event);
|
||||
|
||||
class AgUiService {
|
||||
@@ -27,8 +39,8 @@ class AgUiService {
|
||||
}
|
||||
|
||||
Future<void> _mockEventStream(String content) async {
|
||||
final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
|
||||
@@ -58,7 +70,8 @@ class AgUiService {
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final toolCallId =
|
||||
'$_toolCallIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
|
||||
|
||||
@@ -82,7 +95,8 @@ class AgUiService {
|
||||
ToolRegistry.initialize();
|
||||
final result = await ToolRegistry.execute(toolName, args);
|
||||
final ui = _buildUiCard(toolName, result);
|
||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final messageId =
|
||||
'$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(
|
||||
ToolCallResultEvent(
|
||||
@@ -145,20 +159,20 @@ class AgUiService {
|
||||
|
||||
Future<void> _mockTextMessageStream(List<String> replies) async {
|
||||
for (final reply in replies) {
|
||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final messageId =
|
||||
'$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
|
||||
|
||||
const chunkSize = 10;
|
||||
for (var i = 0; i < reply.length; i += chunkSize) {
|
||||
final end = (i + chunkSize < reply.length)
|
||||
? i + chunkSize
|
||||
for (var i = 0; i < reply.length; i += _textChunkSize) {
|
||||
final end = (i + _textChunkSize < reply.length)
|
||||
? i + _textChunkSize
|
||||
: reply.length;
|
||||
final chunk = reply.substring(i, end);
|
||||
|
||||
onEvent(TextMessageContentEvent(messageId: messageId, delta: chunk));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
await Future.delayed(const Duration(milliseconds: _streamChunkDelayMs));
|
||||
}
|
||||
|
||||
onEvent(TextMessageEndEvent(messageId: messageId));
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
typedef ToolHandler =
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
|
||||
|
||||
/// 工具常量
|
||||
const _toolNameCreateCalendar = 'create_calendar_event';
|
||||
const _defaultTimezone = 'Asia/Shanghai';
|
||||
const _defaultEventColor = '#4F46E5';
|
||||
const _defaultSourceType = 'agentGenerated';
|
||||
const _titleMinLength = 1;
|
||||
const _titleMaxLength = 100;
|
||||
|
||||
class ToolDefinition {
|
||||
final String name;
|
||||
final String description;
|
||||
@@ -22,8 +30,8 @@ class ToolRegistry {
|
||||
static void initialize() {
|
||||
if (_initialized) return;
|
||||
|
||||
_tools['create_calendar_event'] = ToolDefinition(
|
||||
name: 'create_calendar_event',
|
||||
_tools[_toolNameCreateCalendar] = ToolDefinition(
|
||||
name: _toolNameCreateCalendar,
|
||||
description: '创建一个日历事件或待办事项',
|
||||
parameters: {
|
||||
'type': 'object',
|
||||
@@ -31,8 +39,8 @@ class ToolRegistry {
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'description': '事件标题',
|
||||
'minLength': 1,
|
||||
'maxLength': 100,
|
||||
'minLength': _titleMinLength,
|
||||
'maxLength': _titleMaxLength,
|
||||
},
|
||||
'description': {'type': 'string', 'description': '事件描述'},
|
||||
'startAt': {
|
||||
@@ -45,7 +53,7 @@ class ToolRegistry {
|
||||
'format': 'date-time',
|
||||
'description': '结束时间 (ISO8601)',
|
||||
},
|
||||
'timezone': {'type': 'string', 'default': 'Asia/Shanghai'},
|
||||
'timezone': {'type': 'string', 'default': _defaultTimezone},
|
||||
'location': {'type': 'string'},
|
||||
'notes': {'type': 'string'},
|
||||
},
|
||||
@@ -69,10 +77,10 @@ class ToolRegistry {
|
||||
'description': args['description'],
|
||||
'startAt': args['startAt'],
|
||||
'endAt': args['endAt'],
|
||||
'timezone': args['timezone'] ?? 'Asia/Shanghai',
|
||||
'timezone': args['timezone'] ?? _defaultTimezone,
|
||||
'location': args['location'],
|
||||
'color': '#4F46E5',
|
||||
'sourceType': 'agentGenerated',
|
||||
'color': _defaultEventColor,
|
||||
'sourceType': _defaultSourceType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,17 +101,19 @@ class ToolRegistry {
|
||||
Map<String, dynamic> args,
|
||||
) {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null)
|
||||
if (tool == null) {
|
||||
return ToolValidationResult(
|
||||
ok: false,
|
||||
error: 'Tool not found: $toolName',
|
||||
);
|
||||
}
|
||||
|
||||
final required = tool.parameters['required'] as List<dynamic>? ?? [];
|
||||
final missing = <String>[];
|
||||
for (final field in required) {
|
||||
if (!args.containsKey(field) || args[field] == null)
|
||||
if (!args.containsKey(field) || args[field] == null) {
|
||||
missing.add(field as String);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/ag_ui_event.dart';
|
||||
import '../../data/models/chat_list_item.dart';
|
||||
import '../../data/models/tool_result.dart';
|
||||
import '../../data/services/ag_ui_service.dart';
|
||||
|
||||
class ChatState {
|
||||
final List<ChatListItem> items;
|
||||
@@ -38,20 +39,12 @@ class ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
class AgUiService {
|
||||
void Function(AgUiEvent)? onEvent;
|
||||
|
||||
AgUiService({this.onEvent});
|
||||
|
||||
Future<void> sendMessage(String content) async {}
|
||||
}
|
||||
|
||||
class ChatBloc extends Cubit<ChatState> {
|
||||
final AgUiService _service;
|
||||
final Map<String, String> _toolCallArgsBuffer = {};
|
||||
|
||||
ChatBloc({AgUiService? service})
|
||||
: _service = service ?? AgUiService(onEvent: (_) {}),
|
||||
: _service = service ?? AgUiService(),
|
||||
super(const ChatState()) {
|
||||
_service.onEvent = _handleEvent;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import '../../data/models/tool_result.dart';
|
||||
|
||||
/// 卡片类型常量
|
||||
const _calendarCardType = 'calendar_card.v1';
|
||||
const _errorCardType = 'error_card.v1';
|
||||
const _aiGeneratedSource = 'ai_generated';
|
||||
const _primaryActionType = 'primary';
|
||||
|
||||
class UiSchemaRenderer {
|
||||
static Widget render(UiCard card) {
|
||||
switch (card.cardType) {
|
||||
case 'calendar_card.v1':
|
||||
return _renderCalendarCard(card);
|
||||
case 'error_card.v1':
|
||||
return _renderErrorCard(card);
|
||||
default:
|
||||
return _renderUnknownCard(card);
|
||||
}
|
||||
return switch (card.cardType) {
|
||||
_calendarCardType => _renderCalendarCard(card),
|
||||
_errorCardType => _renderErrorCard(card),
|
||||
_ => _renderUnknownCard(card),
|
||||
};
|
||||
}
|
||||
|
||||
static Widget _renderCalendarCard(UiCard card) {
|
||||
@@ -19,6 +22,7 @@ class UiSchemaRenderer {
|
||||
final color = data.color != null
|
||||
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
||||
: AppColors.blue500;
|
||||
final isAiGenerated = data.sourceType == _aiGeneratedSource;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -44,23 +48,10 @@ class UiSchemaRenderer {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (data.sourceType == 'ai_generated')
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageTagBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
'AI生成',
|
||||
style: TextStyle(fontSize: 10, color: AppColors.blue600),
|
||||
),
|
||||
),
|
||||
if (data.sourceType == 'ai_generated')
|
||||
if (isAiGenerated) ...[
|
||||
_buildAiTag(),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
],
|
||||
Text(
|
||||
_formatTime(data.startAt, data.endAt),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
@@ -83,32 +74,11 @@ class UiSchemaRenderer {
|
||||
],
|
||||
if (data.location != null) ...[
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 16,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
data.location!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildLocation(data.location!),
|
||||
],
|
||||
if (card.actions != null && card.actions!.isNotEmpty) ...[
|
||||
SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
children: card.actions!
|
||||
.map((action) => _buildActionButton(action))
|
||||
.toList(),
|
||||
),
|
||||
_buildActions(card.actions!),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -118,8 +88,45 @@ class UiSchemaRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildAiTag() {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageTagBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
'AI生成',
|
||||
style: TextStyle(fontSize: 10, color: AppColors.blue600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildLocation(String location) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_outlined, size: 16, color: AppColors.slate500),
|
||||
SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildActions(List<CardAction> actions) {
|
||||
return Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
children: actions.map((action) => _buildActionButton(action)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildActionButton(CardAction action) {
|
||||
final isPrimary = action.type == 'primary';
|
||||
final isPrimary = action.type == _primaryActionType;
|
||||
return GestureDetector(
|
||||
onTap: () => _handleAction(action),
|
||||
child: Container(
|
||||
|
||||
@@ -10,6 +10,25 @@ import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import 'home_sheet.dart';
|
||||
|
||||
/// 布局常量
|
||||
const _headerHeight = 60.0;
|
||||
const _defaultPadding = 20.0;
|
||||
const _itemSpacing = 16.0;
|
||||
const _inputPadding = 16.0;
|
||||
const _iconSize = 24.0;
|
||||
const _avatarSize = 32.0;
|
||||
const _botIconSize = 18.0;
|
||||
const _messagePaddingH = 13.0;
|
||||
const _messagePaddingV = 9.0;
|
||||
const _cornerRadius = 12.0;
|
||||
const _inputMinHeight = 48.0;
|
||||
const _inputRadius = 24.0;
|
||||
const _scrollDurationMs = 300;
|
||||
|
||||
/// 颜色常量
|
||||
const _chatBgColor = Color(0xFFF8FAFC);
|
||||
const _userBubbleColor = Color(0xFFEAF1FB);
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@@ -53,7 +72,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8FAFC),
|
||||
backgroundColor: _chatBgColor,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -71,16 +90,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
height: _headerHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.symmetric(horizontal: _defaultPadding),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.settings,
|
||||
size: 24,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
onPressed: () => context.push('/settings'),
|
||||
@@ -90,16 +109,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.calendar,
|
||||
size: 24,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
onPressed: () => context.push('/calendar/dayweek?from=home'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: _itemSpacing),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.messageSquare,
|
||||
size: 24,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
onPressed: () => context.push('/messages/invites'),
|
||||
@@ -126,7 +145,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
@@ -134,12 +153,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(_defaultPadding),
|
||||
itemCount: state.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.only(bottom: _itemSpacing),
|
||||
child: _buildChatItem(item),
|
||||
);
|
||||
},
|
||||
@@ -167,15 +186,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue100,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bot,
|
||||
size: 18,
|
||||
size: _botIconSize,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
@@ -183,14 +202,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _messagePaddingH,
|
||||
vertical: _messagePaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? const Color(0xFFEAF1FB) : AppColors.white,
|
||||
color: isUser ? _userBubbleColor : AppColors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(12),
|
||||
topRight: const Radius.circular(12),
|
||||
bottomLeft: Radius.circular(isUser ? 12 : 0),
|
||||
bottomRight: Radius.circular(isUser ? 0 : 12),
|
||||
topLeft: const Radius.circular(_cornerRadius),
|
||||
topRight: const Radius.circular(_cornerRadius),
|
||||
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
|
||||
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
|
||||
),
|
||||
border: isUser ? null : Border.all(color: AppColors.slate300),
|
||||
),
|
||||
@@ -207,32 +229,28 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Widget _buildToolCallItem(ToolCallItem item) {
|
||||
String statusText;
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
|
||||
switch (item.status) {
|
||||
case ToolCallStatus.pending:
|
||||
statusText = '准备中...';
|
||||
statusColor = AppColors.slate500;
|
||||
statusIcon = LucideIcons.clock;
|
||||
break;
|
||||
case ToolCallStatus.executing:
|
||||
statusText = '执行中...';
|
||||
statusColor = AppColors.blue600;
|
||||
statusIcon = LucideIcons.loader;
|
||||
break;
|
||||
case ToolCallStatus.error:
|
||||
statusText = item.errorMessage ?? '执行失败';
|
||||
statusColor = AppColors.red600;
|
||||
statusIcon = LucideIcons.alertCircle;
|
||||
break;
|
||||
case ToolCallStatus.completed:
|
||||
statusText = '已完成';
|
||||
statusColor = AppColors.emerald600;
|
||||
statusIcon = LucideIcons.checkCircle;
|
||||
break;
|
||||
}
|
||||
final (statusText, statusColor, statusIcon) = switch (item.status) {
|
||||
ToolCallStatus.pending => (
|
||||
'准备中...',
|
||||
AppColors.slate500,
|
||||
LucideIcons.clock,
|
||||
),
|
||||
ToolCallStatus.executing => (
|
||||
'执行中...',
|
||||
AppColors.blue600,
|
||||
LucideIcons.loader,
|
||||
),
|
||||
ToolCallStatus.error => (
|
||||
item.errorMessage ?? '执行失败',
|
||||
AppColors.red600,
|
||||
LucideIcons.alertCircle,
|
||||
),
|
||||
ToolCallStatus.completed => (
|
||||
'已完成',
|
||||
AppColors.emerald600,
|
||||
LucideIcons.checkCircle,
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -267,8 +285,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
Widget _buildInputContainer(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: const Color(0xFFF8FAFC),
|
||||
padding: const EdgeInsets.all(_inputPadding),
|
||||
color: _chatBgColor,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -292,11 +310,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
constraints: const BoxConstraints(minHeight: _inputMinHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(_inputRadius),
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Row(
|
||||
@@ -327,7 +345,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onTap: _hasMessage ? () => _sendMessage(context) : null,
|
||||
child: Icon(
|
||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
||||
size: 24,
|
||||
size: _iconSize,
|
||||
color: _hasMessage
|
||||
? AppColors.blue600
|
||||
: AppColors.slate500,
|
||||
|
||||
@@ -2,14 +2,22 @@ import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
|
||||
class MockAgUiService extends AgUiService {
|
||||
MockAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content) async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ChatBloc chatBloc;
|
||||
late AgUiService service;
|
||||
|
||||
setUp(() {
|
||||
service = AgUiService();
|
||||
service = MockAgUiService();
|
||||
chatBloc = ChatBloc(service: service);
|
||||
});
|
||||
|
||||
@@ -49,7 +57,7 @@ void main() {
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
bloc.emit(chatBloc.state.copyWith(isLoading: true));
|
||||
service.onEvent!(
|
||||
service.onEvent(
|
||||
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
|
||||
);
|
||||
},
|
||||
@@ -86,7 +94,7 @@ void main() {
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent!(
|
||||
service.onEvent(
|
||||
TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'),
|
||||
);
|
||||
},
|
||||
@@ -115,7 +123,7 @@ void main() {
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent!(TextMessageEndEvent(messageId: 'msg_1'));
|
||||
service.onEvent(TextMessageEndEvent(messageId: 'msg_1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
@@ -132,7 +140,7 @@ void main() {
|
||||
'runStarted sets isLoading to true',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent!(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
@@ -146,7 +154,7 @@ void main() {
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isLoading: true),
|
||||
act: (bloc) {
|
||||
service.onEvent!(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
||||
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
@@ -160,7 +168,7 @@ void main() {
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isLoading: true),
|
||||
act: (bloc) {
|
||||
service.onEvent!(
|
||||
service.onEvent(
|
||||
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
|
||||
);
|
||||
},
|
||||
@@ -183,7 +191,7 @@ void main() {
|
||||
'toolCallStart adds ToolCallItem',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent!(
|
||||
service.onEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tc_1',
|
||||
toolCallName: 'create_calendar_event',
|
||||
|
||||
Reference in New Issue
Block a user