feat: 应用名称更新为灵可析并增强 Chat 功能
- 更新 Android/iOS 应用名称和图标为灵可析 - Chat 支持取消正在运行的 Agent 对话 - 改进 ChatBloc 状态管理(区分发送/等待/流式/取消状态) - HomeScreen 支持外部注入 ChatBloc 和显示等待指示器 - 后端 Agent 运行服务优化(消息处理、usage 追踪) - 补充相关单元测试和 Widget 测试
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user