feat: 应用名称更新为灵可析并增强 Chat 功能

- 更新 Android/iOS 应用名称和图标为灵可析
- Chat 支持取消正在运行的 Agent 对话
- 改进 ChatBloc 状态管理(区分发送/等待/流式/取消状态)
- HomeScreen 支持外部注入 ChatBloc 和显示等待指示器
- 后端 Agent 运行服务优化(消息处理、usage 追踪)
- 补充相关单元测试和 Widget 测试
This commit is contained in:
qzl
2026-03-10 18:39:53 +08:00
parent b48f7abf72
commit 487405aa5b
50 changed files with 768 additions and 284 deletions
@@ -4,7 +4,7 @@
<application
android:usesCleartextTraffic="true"
android:label="social_app"
android:label="灵可析"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
+2 -2
View File
@@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Social App</string>
<string>灵可析</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>social_app</string>
<string>灵可析</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -25,6 +25,7 @@ class AgUiService {
final MockHistoryService _historyService;
final Map<String, List<String>> _mockSseLinesByThread = {};
final Map<String, String> _lastEventIdByThread = {};
int _activeStreamToken = 0;
String? _threadId;
bool _hasMoreHistory = false;
@@ -41,6 +42,7 @@ class AgUiService {
}
Future<void> sendMessage(String content) async {
final streamToken = ++_activeStreamToken;
final runInput = _buildRunInput(content: content);
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/runs',
@@ -55,7 +57,7 @@ class AgUiService {
throw StateError('Missing threadId in /agent/runs response');
}
_threadId = threadId;
await _streamEventsFromApi(threadId);
await _streamEventsFromApi(threadId, streamToken: streamToken);
}
Future<void> loadHistory({DateTime? beforeDate}) async {
@@ -105,6 +107,7 @@ class AgUiService {
required String toolName,
required Map<String, dynamic> args,
}) async {
final streamToken = ++_activeStreamToken;
final threadId = _threadId;
if (threadId == null || threadId.isEmpty) {
throw StateError('Missing threadId for resume');
@@ -150,7 +153,7 @@ class AgUiService {
_threadId = responseThreadId;
}
}
await _streamEventsFromApi(threadId);
await _streamEventsFromApi(threadId, streamToken: streamToken);
}
bool hasEarlierHistory(DateTime fromDate) {
@@ -160,7 +163,14 @@ class AgUiService {
return _hasMoreHistory;
}
Future<void> _streamEventsFromApi(String threadId) async {
Future<void> cancelCurrentRun() async {
_activeStreamToken += 1;
}
Future<void> _streamEventsFromApi(
String threadId, {
required int streamToken,
}) async {
final lastEventId = _lastEventIdByThread[threadId];
final headers = <String, String>{'Accept': 'text/event-stream'};
if (lastEventId != null && lastEventId.isNotEmpty) {
@@ -175,6 +185,9 @@ class AgUiService {
String? eventId;
final dataBuffer = StringBuffer();
await for (final line in sseLines) {
if (streamToken != _activeStreamToken) {
break;
}
if (line.isEmpty) {
if (dataBuffer.isNotEmpty) {
final raw = dataBuffer.toString();
@@ -11,7 +11,11 @@ import '../../data/services/ag_ui_service.dart';
class ChatState {
final List<ChatListItem> items;
final bool isLoading;
final bool isSending;
final bool isWaitingFirstToken;
final bool isStreaming;
final bool isCancelling;
final bool isLoadingHistory;
final String? currentMessageId;
final String? error;
final DateTime? oldestLoadedDate;
@@ -19,18 +23,33 @@ class ChatState {
const ChatState({
this.items = const [],
this.isLoading = false,
this.isSending = false,
this.isWaitingFirstToken = false,
this.isStreaming = false,
this.isCancelling = false,
this.isLoadingHistory = false,
this.currentMessageId,
this.error,
this.oldestLoadedDate,
this.hasEarlierHistory = false,
});
bool get isLoading =>
isSending ||
isWaitingFirstToken ||
isStreaming ||
isCancelling ||
isLoadingHistory;
static const _unset = Object();
ChatState copyWith({
List<ChatListItem>? items,
bool? isLoading,
bool? isSending,
bool? isWaitingFirstToken,
bool? isStreaming,
bool? isCancelling,
bool? isLoadingHistory,
Object? currentMessageId = _unset,
Object? error = _unset,
Object? oldestLoadedDate = _unset,
@@ -38,7 +57,11 @@ class ChatState {
}) {
return ChatState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
isSending: isSending ?? this.isSending,
isWaitingFirstToken: isWaitingFirstToken ?? this.isWaitingFirstToken,
isStreaming: isStreaming ?? this.isStreaming,
isCancelling: isCancelling ?? this.isCancelling,
isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory,
currentMessageId: currentMessageId == _unset
? this.currentMessageId
: currentMessageId as String?,
@@ -72,12 +95,36 @@ class ChatBloc extends Cubit<ChatState> {
void _handleEvent(AgUiEvent event) {
switch (event.type) {
case AgUiEventType.runStarted:
emit(state.copyWith(isLoading: true, error: null));
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: true,
isCancelling: false,
error: null,
),
);
case AgUiEventType.runFinished:
emit(state.copyWith(isLoading: false, currentMessageId: null));
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
),
);
case AgUiEventType.runError:
final errorEvent = event as RunErrorEvent;
emit(state.copyWith(isLoading: false, error: errorEvent.message));
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
error: errorEvent.message,
),
);
case AgUiEventType.textMessageStart:
_handleTextMessageStart(event as TextMessageStartEvent);
case AgUiEventType.textMessageContent:
@@ -115,6 +162,8 @@ class ChatBloc extends Cubit<ChatState> {
state.copyWith(
items: [...state.items, newMessage],
currentMessageId: startEvent.messageId,
isWaitingFirstToken: false,
isStreaming: true,
),
);
}
@@ -136,7 +185,13 @@ class ChatBloc extends Cubit<ChatState> {
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems, currentMessageId: null));
emit(
state.copyWith(
items: updatedItems,
currentMessageId: null,
isStreaming: false,
),
);
}
void _handleToolCallStart(ToolCallStartEvent startEvent) {
@@ -319,20 +374,50 @@ class ChatBloc extends Cubit<ChatState> {
timestamp: DateTime.now(),
sender: MessageSender.user,
);
emit(state.copyWith(items: [...state.items, userMessage]));
await _service.sendMessage(content);
emit(
state.copyWith(
items: [...state.items, userMessage],
isSending: true,
isWaitingFirstToken: true,
isStreaming: false,
isCancelling: false,
error: null,
),
);
try {
await _service.sendMessage(content);
} catch (error) {
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
error: error.toString(),
),
);
}
}
Future<void> loadHistory() async {
if (state.isLoading) return;
await _service.loadHistory();
if (state.isLoadingHistory) return;
emit(state.copyWith(isLoadingHistory: true));
try {
await _service.loadHistory();
} finally {
emit(state.copyWith(isLoadingHistory: false));
}
}
Future<void> loadMoreHistory() async {
if (state.isLoading || !state.hasEarlierHistory) return;
if (state.isLoadingHistory || !state.hasEarlierHistory) return;
if (state.oldestLoadedDate == null) return;
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
emit(state.copyWith(isLoadingHistory: true));
try {
await _service.loadHistory(beforeDate: state.oldestLoadedDate);
} finally {
emit(state.copyWith(isLoadingHistory: false));
}
}
Future<void> approveToolCall(String toolCallId) async {
@@ -355,7 +440,16 @@ class ChatBloc extends Cubit<ChatState> {
}
return item;
}).toList();
emit(state.copyWith(items: updatedItems, isLoading: true, error: null));
emit(
state.copyWith(
items: updatedItems,
isSending: false,
isWaitingFirstToken: true,
isStreaming: false,
isCancelling: false,
error: null,
),
);
try {
await _service.approveToolCall(
toolCallId: target.callId,
@@ -375,7 +469,10 @@ class ChatBloc extends Cubit<ChatState> {
emit(
state.copyWith(
items: failedItems,
isLoading: false,
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
error: error.toString(),
),
);
@@ -386,6 +483,31 @@ class ChatBloc extends Cubit<ChatState> {
return _service.transcribeAudio(filePath);
}
Future<bool> cancelCurrentRun() async {
if (!(state.isWaitingFirstToken ||
state.isStreaming ||
state.isCancelling)) {
return false;
}
emit(state.copyWith(isCancelling: true, error: null));
try {
await _service.cancelCurrentRun();
emit(
state.copyWith(
isSending: false,
isWaitingFirstToken: false,
isStreaming: false,
isCancelling: false,
currentMessageId: null,
),
);
return true;
} catch (error) {
emit(state.copyWith(isCancelling: false, error: error.toString()));
return false;
}
}
void clearError() {
emit(state.copyWith(error: null));
}
@@ -31,6 +31,8 @@ const _rippleDurationMs = 1200;
const _recordingDotSize = 10.0;
const _transcribingSpinnerSize = 18.0;
const _transcribingStrokeWidth = 2.0;
const _inputActionButtonKey = ValueKey('home_input_action_button');
const _inputActionIconKey = ValueKey('home_input_action_icon');
/// 颜色常量
const _chatBgColor = Color(0xFFF8FAFC);
@@ -40,6 +42,7 @@ class HomeScreen extends StatefulWidget {
final VoiceRecorder? voiceRecorder;
final Future<String> Function(String filePath)? onTranscribeAudio;
final Future<void> Function(String transcript)? onAutoSendTranscript;
final ChatBloc? chatBloc;
final bool autoLoadHistory;
const HomeScreen({
@@ -47,6 +50,7 @@ class HomeScreen extends StatefulWidget {
this.voiceRecorder,
this.onTranscribeAudio,
this.onAutoSendTranscript,
this.chatBloc,
this.autoLoadHistory = true,
});
@@ -72,7 +76,7 @@ class _HomeScreenState extends State<HomeScreen>
void initState() {
super.initState();
_messageController.addListener(_onMessageChanged);
_chatBloc = ChatBloc();
_chatBloc = widget.chatBloc ?? ChatBloc();
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_transcribeAudio =
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
@@ -93,7 +97,9 @@ class _HomeScreenState extends State<HomeScreen>
_scrollController.dispose();
_listeningAnimationController.dispose();
_voiceRecorder.dispose();
_chatBloc.close();
if (widget.chatBloc == null) {
_chatBloc.close();
}
RouteNavigationTool.instance.clearNavigator();
super.dispose();
}
@@ -131,7 +137,7 @@ class _HomeScreenState extends State<HomeScreen>
children: [
_buildHeader(context),
Expanded(child: _buildChatArea(context, state)),
_buildInputContainer(context),
_buildInputContainer(context, state),
],
),
),
@@ -185,49 +191,100 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildChatArea(BuildContext context, ChatState state) {
if (state.isLoading && state.items.isEmpty) {
final showWaitingIndicator =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
if (state.isLoadingHistory && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.items.isEmpty) {
return const Center(
child: Text(
'开始对话吧',
style: TextStyle(fontSize: 16, color: AppColors.slate400),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: Center(
child: Text(
'开始对话吧',
style: TextStyle(fontSize: 16, color: AppColors.slate400),
),
),
),
if (showWaitingIndicator) _buildWaitingIndicator(),
],
);
}
return RefreshIndicator(
onRefresh: () => _onRefresh(context),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(_defaultPadding),
itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0 && state.hasEarlierHistory) {
return _buildLoadMoreButton(context, state.isLoading);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: RefreshIndicator(
onRefresh: () => _onRefresh(context),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(_defaultPadding),
itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0 && state.hasEarlierHistory) {
return _buildLoadMoreButton(context, state.isLoadingHistory);
}
final itemIndex = state.hasEarlierHistory ? index - 1 : index;
final item = state.items[itemIndex];
final itemIndex = state.hasEarlierHistory ? index - 1 : index;
final item = state.items[itemIndex];
final showDateDivider =
itemIndex == 0 ||
!_isSameDay(state.items[itemIndex - 1].timestamp, item.timestamp);
final showDateDivider =
itemIndex == 0 ||
!_isSameDay(
state.items[itemIndex - 1].timestamp,
item.timestamp,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showDateDivider) _buildDateDivider(item.timestamp),
Padding(
padding: const EdgeInsets.only(bottom: _itemSpacing),
child: _buildChatItem(item),
),
],
);
},
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showDateDivider) _buildDateDivider(item.timestamp),
Padding(
padding: const EdgeInsets.only(bottom: _itemSpacing),
child: _buildChatItem(item),
),
],
);
},
),
),
),
if (showWaitingIndicator) _buildWaitingIndicator(),
],
);
}
Widget _buildWaitingIndicator() {
return Padding(
padding: const EdgeInsets.fromLTRB(
_defaultPadding,
0,
_defaultPadding,
_defaultPadding,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
),
SizedBox(width: 8),
Text(
'正在思考...',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
],
),
);
}
@@ -406,7 +463,9 @@ class _HomeScreenState extends State<HomeScreen>
return UiSchemaRenderer.render(item.uiCard);
}
Widget _buildInputContainer(BuildContext context) {
Widget _buildInputContainer(BuildContext context, ChatState state) {
final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
return Container(
padding: const EdgeInsets.all(_inputPadding),
color: _chatBgColor,
@@ -471,10 +530,13 @@ class _HomeScreenState extends State<HomeScreen>
),
const SizedBox(width: 8),
GestureDetector(
key: _inputActionButtonKey,
onTap: _isTranscribing
? null
: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: true)
: isWaitingAgent
? () => _onStopGenerating(context)
: _hasMessage
? () => _sendMessage(context)
: _startRecording,
@@ -488,11 +550,14 @@ class _HomeScreenState extends State<HomeScreen>
),
)
: Icon(
_isRecording || _hasMessage
key: _inputActionIconKey,
_isRecording || isWaitingAgent
? LucideIcons.square
: _hasMessage
? LucideIcons.send
: LucideIcons.mic,
size: _iconSize,
color: _isRecording || _hasMessage
color: _isRecording || isWaitingAgent || _hasMessage
? AppColors.blue600
: AppColors.slate500,
),
@@ -511,7 +576,7 @@ class _HomeScreenState extends State<HomeScreen>
if (content.isEmpty) return;
FocusScope.of(context).unfocus();
_messageController.clear();
context.read<ChatBloc>().sendMessage(content);
await context.read<ChatBloc>().sendMessage(content);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
@@ -524,6 +589,16 @@ class _HomeScreenState extends State<HomeScreen>
});
}
Future<void> _onStopGenerating(BuildContext context) async {
final canceled = await context.read<ChatBloc>().cancelCurrentRun();
if (!mounted) {
return;
}
if (canceled) {
Toast.show(context, '已停止等待回复', type: ToastType.info);
}
}
Widget _buildListeningIndicator() {
return SizedBox(
height: _inputMinHeight,
+9
View File
@@ -31,8 +31,17 @@ dev_dependencies:
mocktail: ^1.0.4
json_serializable: ^6.7.1
build_runner: ^2.4.8
flutter_launcher_icons: ^0.14.0
flutter:
uses-material-design: true
assets:
- assets/images/
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/images/logo.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/images/logo.png"
remove_alpha_ios: true
+54 -6
View File
@@ -12,6 +12,15 @@ class MockAgUiService extends AgUiService {
Future<void> sendMessage(String content) async {}
}
class _ThrowingAgUiService extends AgUiService {
_ThrowingAgUiService() : super(onEvent: (_) {});
@override
Future<void> sendMessage(String content) async {
throw StateError('network down');
}
}
void main() {
late ChatBloc chatBloc;
late AgUiService service;
@@ -29,6 +38,9 @@ void main() {
test('initial state is empty', () {
expect(chatBloc.state.items, isEmpty);
expect(chatBloc.state.isLoading, false);
expect(chatBloc.state.isSending, false);
expect(chatBloc.state.isWaitingFirstToken, false);
expect(chatBloc.state.isStreaming, false);
expect(chatBloc.state.currentMessageId, isNull);
expect(chatBloc.state.error, isNull);
});
@@ -40,6 +52,12 @@ void main() {
expect: () => [
isA<ChatState>()
.having((state) => state.items.length, 'items length', 1)
.having((state) => state.isSending, 'isSending', true)
.having(
(state) => state.isWaitingFirstToken,
'isWaitingFirstToken',
true,
)
.having(
(state) => state.items.first,
'first item',
@@ -56,15 +74,13 @@ void main() {
'textMessageStart event adds AI message with streaming',
build: () => chatBloc,
act: (bloc) {
bloc.emit(chatBloc.state.copyWith(isLoading: true));
bloc.emit(chatBloc.state.copyWith(isStreaming: true));
service.onEvent(
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
);
},
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', true)
.having((s) => s.isLoading, 'isLoading', true),
isA<ChatState>().having((s) => s.isStreaming, 'isStreaming', true),
isA<ChatState>()
.having((s) => s.items.length, 'items length', 1)
.having((s) => s.currentMessageId, 'currentMessageId', 'msg_1')
@@ -128,6 +144,7 @@ void main() {
expect: () => [
isA<ChatState>()
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
.having((s) => s.isStreaming, 'isStreaming', false)
.having(
(s) => (s.items.first as TextMessageItem).isStreaming,
'isStreaming',
@@ -145,6 +162,7 @@ void main() {
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', true)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
.having((s) => s.error, 'error', isNull),
],
);
@@ -152,7 +170,7 @@ void main() {
blocTest<ChatBloc, ChatState>(
'runFinished sets isLoading to false',
build: () => chatBloc,
seed: () => const ChatState(isLoading: true),
seed: () => const ChatState(isWaitingFirstToken: true),
act: (bloc) {
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
},
@@ -166,7 +184,7 @@ void main() {
blocTest<ChatBloc, ChatState>(
'runError sets error message',
build: () => chatBloc,
seed: () => const ChatState(isLoading: true),
seed: () => const ChatState(isWaitingFirstToken: true),
act: (bloc) {
service.onEvent(
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
@@ -175,10 +193,40 @@ void main() {
expect: () => [
isA<ChatState>()
.having((s) => s.isLoading, 'isLoading', false)
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
.having((s) => s.error, 'error', 'Something went wrong'),
],
);
blocTest<ChatBloc, ChatState>(
'cancelCurrentRun exits waiting states',
build: () => chatBloc,
seed: () => const ChatState(isWaitingFirstToken: true),
act: (bloc) => bloc.cancelCurrentRun(),
expect: () => [
isA<ChatState>().having((s) => s.isCancelling, 'isCancelling', true),
isA<ChatState>()
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
.having((s) => s.isStreaming, 'isStreaming', false)
.having((s) => s.isCancelling, 'isCancelling', false),
],
);
blocTest<ChatBloc, ChatState>(
'sendMessage failure emits error and exits waiting state',
build: () => ChatBloc(service: _ThrowingAgUiService()),
act: (bloc) => bloc.sendMessage('hello'),
expect: () => [
isA<ChatState>()
.having((s) => s.isSending, 'isSending', true)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true),
isA<ChatState>()
.having((s) => s.isSending, 'isSending', false)
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
.having((s) => s.error, 'error', contains('network down')),
],
);
blocTest<ChatBloc, ChatState>(
'clearError removes error',
build: () => chatBloc,
@@ -5,6 +5,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/api/api_exception.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
import 'package:social_app/features/home/data/voice_recorder.dart';
import 'package:social_app/features/home/ui/screens/home_screen.dart';
@@ -29,7 +32,26 @@ class _FakeVoiceRecorder implements VoiceRecorder {
Future<void> dispose() async {}
}
class _WaitingAgUiService extends AgUiService {
_WaitingAgUiService() : super(onEvent: (_) {});
final Completer<void> _pending = Completer<void>();
@override
Future<void> sendMessage(String content) async {
onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
return _pending.future;
}
}
void main() {
IconData _inputActionIcon(WidgetTester tester) {
final icon = tester.widget<Icon>(
find.byKey(const ValueKey('home_input_action_icon')),
);
return icon.icon!;
}
group('HomeScreen Widget Tests', () {
testWidgets('displays input field', (WidgetTester tester) async {
await tester.pumpWidget(
@@ -79,8 +101,7 @@ void main() {
expect(fakeRecorder.started, true);
expect(find.text('正在聆听...'), findsOneWidget);
expect(find.byIcon(LucideIcons.square), findsOneWidget);
expect(find.byIcon(LucideIcons.send), findsOneWidget);
expect(_inputActionIcon(tester), LucideIcons.square);
});
testWidgets('tap send while recording transcribes and auto sends message', (
@@ -105,9 +126,9 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.send));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump(const Duration(milliseconds: 300));
expect(sentTranscript, '语音自动发送');
@@ -127,18 +148,19 @@ void main() {
expect(filePath.endsWith('.wav'), true);
return '语音转文字结果';
},
onAutoSendTranscript: (_) async {},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(find.text('语音识别中...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1));
});
testWidgets('tap stop shows readable unauthorized message', (
@@ -158,9 +180,9 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('请重新登录'), findsOneWidget);
@@ -182,9 +204,9 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
@@ -203,14 +225,15 @@ void main() {
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) => completer.future,
onAutoSendTranscript: (_) async {},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(find.text('语音识别中...'), findsOneWidget);
@@ -237,7 +260,7 @@ void main() {
);
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
await tester.tap(find.byIcon(LucideIcons.send));
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
final editableAfter = tester.state<EditableTextState>(
@@ -247,5 +270,33 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
});
testWidgets('shows stop icon and waiting indicator while waiting agent', (
WidgetTester tester,
) async {
final chatBloc = ChatBloc(service: _WaitingAgUiService());
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(autoLoadHistory: false, chatBloc: chatBloc),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'hello');
await tester.pump();
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(_inputActionIcon(tester), LucideIcons.square);
expect(find.text('正在思考...'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('home_input_action_button')));
await tester.pump();
expect(find.text('已停止等待回复'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
await chatBloc.close();
});
});
}