feat(agent): add voice input capability and standardize tool naming
- Add voice recording with transcribe endpoint (ASR) for multimodal input - Android: add RECORD_AUDIO and INTERNET permissions - Refactor tool naming: frontend tools use 'front.' prefix, backend tools use 'back.' - Migrate calendar tools: create_calendar_event -> back.mutate/list/delete events - Add calendar_event_list.v1 and calendar_operation.v1 UI card types - Update all Flutter and Python tests to match new tool naming conventions - Add record package dependency for voice recording
This commit is contained in:
@@ -82,7 +82,9 @@ class AiDecisionEngine {
|
||||
}
|
||||
|
||||
ForceTriggerResult? tryForceTrigger(String text) {
|
||||
final match = RegExp(r'#tool:(\w+)\s*(\{.*\})?').firstMatch(text);
|
||||
final match = RegExp(
|
||||
r'#tool:([A-Za-z0-9_.-]+)\s*(\{.*\})?',
|
||||
).firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final toolName = match.group(1)!;
|
||||
|
||||
@@ -297,6 +297,14 @@ class ToolCallResultEvent extends AgUiEvent {
|
||||
if (rawUi is Map<String, dynamic>) {
|
||||
return UiCard.fromJson(rawUi);
|
||||
}
|
||||
final rawResult = payload['result'];
|
||||
if (rawResult is Map<String, dynamic>) {
|
||||
final type = rawResult['type'];
|
||||
final data = rawResult['data'];
|
||||
if (type is String && data is Map<String, dynamic>) {
|
||||
return UiCard.fromJson(rawResult);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
|
||||
import '../ai/ai_decision_engine.dart';
|
||||
import '../models/ag_ui_event.dart';
|
||||
import '../models/tool_result.dart';
|
||||
import '../tools/tool_registry.dart';
|
||||
import 'mock_history_service.dart';
|
||||
|
||||
@@ -36,7 +36,7 @@ class AgUiService {
|
||||
_decisionEngine = AiDecisionEngine(),
|
||||
_historyService = MockHistoryService() {
|
||||
if (_apiClient is MockApiClient) {
|
||||
_configureMockAgentApi(_apiClient as MockApiClient);
|
||||
_configureMockAgentApi(_apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,28 @@ class AgUiService {
|
||||
onEvent(event);
|
||||
}
|
||||
|
||||
Future<String> transcribeAudio(String filePath) async {
|
||||
final formData = FormData.fromMap({
|
||||
'audio': await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: 'recording.wav',
|
||||
),
|
||||
});
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/agent/transcribe',
|
||||
data: formData,
|
||||
);
|
||||
final payload = response.data;
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
throw StateError('Invalid /agent/transcribe response');
|
||||
}
|
||||
final transcript = payload['transcript'];
|
||||
if (transcript is! String) {
|
||||
throw StateError('Missing transcript in /agent/transcribe response');
|
||||
}
|
||||
return transcript;
|
||||
}
|
||||
|
||||
Future<void> approveToolCall({
|
||||
required String toolCallId,
|
||||
required String toolName,
|
||||
@@ -210,11 +232,7 @@ class AgUiService {
|
||||
'runId': runId,
|
||||
'state': <String, dynamic>{},
|
||||
'messages': [
|
||||
{
|
||||
'id': _nextId('user_'),
|
||||
'role': 'user',
|
||||
'content': content,
|
||||
},
|
||||
{'id': _nextId('user_'), 'role': 'user', 'content': content},
|
||||
],
|
||||
'tools': _buildTools(),
|
||||
'context': <Map<String, dynamic>>[],
|
||||
@@ -225,33 +243,20 @@ class AgUiService {
|
||||
List<Map<String, dynamic>> _buildTools() {
|
||||
return [
|
||||
{
|
||||
'name': 'navigate_to_route',
|
||||
'name': 'front.navigate_to_route',
|
||||
'description': 'Navigate user to a route in the mobile app.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'target': {'type': 'string', 'description': 'Route path target'},
|
||||
'replace': {'type': 'boolean', 'description': 'Use replace navigation'},
|
||||
'replace': {
|
||||
'type': 'boolean',
|
||||
'description': 'Use replace navigation',
|
||||
},
|
||||
},
|
||||
'required': ['target'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'name': 'create_calendar_event',
|
||||
'description': 'Create a calendar schedule event.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'title': {'type': 'string'},
|
||||
'description': {'type': 'string'},
|
||||
'startAt': {'type': 'string', 'format': 'date-time'},
|
||||
'endAt': {'type': 'string', 'format': 'date-time'},
|
||||
'timezone': {'type': 'string'},
|
||||
'location': {'type': 'string'},
|
||||
},
|
||||
'required': ['title', 'startAt'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -270,7 +275,8 @@ class AgUiService {
|
||||
return '/api/v1/agent/history?${query.join('&')}';
|
||||
}
|
||||
|
||||
String _nextId(String prefix) => '$prefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
String _nextId(String prefix) =>
|
||||
'$prefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
String _newUuid() {
|
||||
final random = Random();
|
||||
@@ -304,6 +310,15 @@ class AgUiService {
|
||||
'SSE',
|
||||
_handleMockSse,
|
||||
);
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/transcribe',
|
||||
'POST',
|
||||
_handleMockTranscribe,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
|
||||
return {'transcript': '这是模拟语音转写'};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockRun(MockRequest request) {
|
||||
@@ -331,9 +346,9 @@ class AgUiService {
|
||||
}
|
||||
|
||||
Map<String, dynamic> _handleMockResume(MockRequest request) {
|
||||
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/resume$').firstMatch(
|
||||
request.path,
|
||||
);
|
||||
final match = RegExp(
|
||||
r'^/api/v1/agent/runs/([^/]+)/resume$',
|
||||
).firstMatch(request.path);
|
||||
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
|
||||
final payload = request.data;
|
||||
final runInput = payload is Map<String, dynamic>
|
||||
@@ -344,7 +359,11 @@ class AgUiService {
|
||||
|
||||
final toolMessage = _extractLatestToolMessage(runInput);
|
||||
final events = <Map<String, dynamic>>[
|
||||
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId},
|
||||
{
|
||||
'type': AgUiEventTypeWire.runStarted,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
},
|
||||
{
|
||||
'type': AgUiEventTypeWire.toolCallResult,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
@@ -365,7 +384,11 @@ class AgUiService {
|
||||
'type': AgUiEventTypeWire.textMessageEnd,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
},
|
||||
{'type': AgUiEventTypeWire.runFinished, 'threadId': threadId, 'runId': runId},
|
||||
{
|
||||
'type': AgUiEventTypeWire.runFinished,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
},
|
||||
];
|
||||
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
||||
return {
|
||||
@@ -398,7 +421,8 @@ class AgUiService {
|
||||
final messages = targetDate == null
|
||||
? <SnapshotMessage>[]
|
||||
: _historyService.getHistoryForDay(targetDate);
|
||||
final hasMore = targetDate != null && _historyService.hasEarlierHistory(targetDate);
|
||||
final hasMore =
|
||||
targetDate != null && _historyService.hasEarlierHistory(targetDate);
|
||||
_hasMoreHistory = hasMore;
|
||||
|
||||
return {
|
||||
@@ -421,9 +445,9 @@ class AgUiService {
|
||||
}
|
||||
|
||||
Stream<String> _handleMockSse(MockRequest request) {
|
||||
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/events$').firstMatch(
|
||||
request.path,
|
||||
);
|
||||
final match = RegExp(
|
||||
r'^/api/v1/agent/runs/([^/]+)/events$',
|
||||
).firstMatch(request.path);
|
||||
final threadId = match?.group(1);
|
||||
if (threadId == null) {
|
||||
return const Stream<String>.empty();
|
||||
@@ -441,7 +465,11 @@ class AgUiService {
|
||||
required String userInput,
|
||||
}) {
|
||||
final events = <Map<String, dynamic>>[
|
||||
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId},
|
||||
{
|
||||
'type': AgUiEventTypeWire.runStarted,
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
},
|
||||
];
|
||||
|
||||
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
|
||||
@@ -451,19 +479,13 @@ class AgUiService {
|
||||
toolName = forceTrigger.toolName;
|
||||
args = forceTrigger.args;
|
||||
} else if (_looksLikeNavigationIntent(userInput)) {
|
||||
toolName = 'navigate_to_route';
|
||||
toolName = 'front.navigate_to_route';
|
||||
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
|
||||
} else if (_decisionEngine.shouldTriggerToolCall(userInput)) {
|
||||
toolName = 'create_calendar_event';
|
||||
args = _decisionEngine.getToolCallArgs(userInput);
|
||||
}
|
||||
|
||||
if (toolName != null && args != null) {
|
||||
if (toolName == 'navigate_to_route') {
|
||||
args = {
|
||||
...args,
|
||||
'__nonce': _nextId('nonce_'),
|
||||
};
|
||||
if (toolName == 'front.navigate_to_route') {
|
||||
args = {...args, '__nonce': _nextId('nonce_')};
|
||||
}
|
||||
final toolCallId = _nextId(_toolCallIdPrefix);
|
||||
events.add({
|
||||
@@ -476,32 +498,20 @@ class AgUiService {
|
||||
'toolCallId': toolCallId,
|
||||
'delta': jsonEncode(args),
|
||||
});
|
||||
events.add({'type': AgUiEventTypeWire.toolCallEnd, 'toolCallId': toolCallId});
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallEnd,
|
||||
'toolCallId': toolCallId,
|
||||
});
|
||||
|
||||
if (toolName == 'navigate_to_route') {
|
||||
if (toolName == 'front.navigate_to_route') {
|
||||
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
|
||||
} else {
|
||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||
if (!validation.ok) {
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallError,
|
||||
'toolCallId': toolCallId,
|
||||
'error': validation.error ?? 'Validation failed',
|
||||
'code': 'VALIDATION_ERROR',
|
||||
});
|
||||
} else {
|
||||
final result = _mockCalendarResult(args);
|
||||
final ui = _buildUiCard(toolName, result);
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallResult,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
'toolCallId': toolCallId,
|
||||
'content': jsonEncode({
|
||||
'result': result,
|
||||
if (ui != null) 'ui': ui.toJson(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallError,
|
||||
'toolCallId': toolCallId,
|
||||
'error': 'Unsupported frontend tool in mock mode',
|
||||
'code': 'UNSUPPORTED_TOOL',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +528,10 @@ class AgUiService {
|
||||
'messageId': messageId,
|
||||
'delta': reply,
|
||||
});
|
||||
events.add({'type': AgUiEventTypeWire.textMessageEnd, 'messageId': messageId});
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.textMessageEnd,
|
||||
'messageId': messageId,
|
||||
});
|
||||
}
|
||||
|
||||
events.add({
|
||||
@@ -577,57 +590,14 @@ class AgUiService {
|
||||
if (raw['role'] != 'tool') {
|
||||
continue;
|
||||
}
|
||||
final toolCallId = raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
|
||||
final toolCallId =
|
||||
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
|
||||
final content = raw['content'] as String? ?? '{}';
|
||||
return (toolCallId, content);
|
||||
}
|
||||
return (_nextId(_toolCallIdPrefix), '{}');
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mockCalendarResult(Map<String, dynamic> args) {
|
||||
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'ok': true,
|
||||
'message': '日程已创建',
|
||||
'title': args['title'],
|
||||
'description': args['description'],
|
||||
'startAt': args['startAt'],
|
||||
'endAt': args['endAt'],
|
||||
'timezone': args['timezone'] ?? 'Asia/Shanghai',
|
||||
'location': args['location'],
|
||||
'color': '#4F46E5',
|
||||
'sourceType': 'agentGenerated',
|
||||
};
|
||||
}
|
||||
|
||||
UiCard? _buildUiCard(String toolName, Map<String, dynamic> result) {
|
||||
if (toolName != 'create_calendar_event') {
|
||||
return null;
|
||||
}
|
||||
return UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: result['eventId'] ?? '',
|
||||
title: result['title'] ?? '',
|
||||
description: result['description'],
|
||||
startAt: result['startAt'] ?? '',
|
||||
endAt: result['endAt'],
|
||||
timezone: result['timezone'],
|
||||
location: result['location'],
|
||||
color: result['color'],
|
||||
sourceType: result['sourceType'],
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(
|
||||
type: 'link',
|
||||
label: '查看详情',
|
||||
target: '/calendar/events/${result['eventId']}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _generateReplies(String content) {
|
||||
final intent = _decisionEngine.matchIntent(content);
|
||||
switch (intent) {
|
||||
|
||||
@@ -4,13 +4,7 @@ typedef ToolHandler =
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
|
||||
|
||||
/// 工具常量
|
||||
const _toolNameCreateCalendar = 'create_calendar_event';
|
||||
const _toolNameNavigateRoute = 'navigate_to_route';
|
||||
const _defaultTimezone = 'Asia/Shanghai';
|
||||
const _defaultEventColor = '#4F46E5';
|
||||
const _defaultSourceType = 'agentGenerated';
|
||||
const _titleMinLength = 1;
|
||||
const _titleMaxLength = 100;
|
||||
const _toolNameNavigateRoute = 'front.navigate_to_route';
|
||||
|
||||
class ToolDefinition {
|
||||
final String name;
|
||||
@@ -33,38 +27,6 @@ class ToolRegistry {
|
||||
static void initialize() {
|
||||
if (_initialized) return;
|
||||
|
||||
_tools[_toolNameCreateCalendar] = ToolDefinition(
|
||||
name: _toolNameCreateCalendar,
|
||||
description: '创建一个日历事件或待办事项',
|
||||
parameters: {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'description': '事件标题',
|
||||
'minLength': _titleMinLength,
|
||||
'maxLength': _titleMaxLength,
|
||||
},
|
||||
'description': {'type': 'string', 'description': '事件描述'},
|
||||
'startAt': {
|
||||
'type': 'string',
|
||||
'format': 'date-time',
|
||||
'description': '开始时间 (ISO8601)',
|
||||
},
|
||||
'endAt': {
|
||||
'type': 'string',
|
||||
'format': 'date-time',
|
||||
'description': '结束时间 (ISO8601)',
|
||||
},
|
||||
'timezone': {'type': 'string', 'default': _defaultTimezone},
|
||||
'location': {'type': 'string'},
|
||||
'notes': {'type': 'string'},
|
||||
},
|
||||
'required': ['title', 'startAt'],
|
||||
},
|
||||
handler: _handleCreateCalendarEvent,
|
||||
);
|
||||
|
||||
_tools[_toolNameNavigateRoute] = ToolDefinition(
|
||||
name: _toolNameNavigateRoute,
|
||||
description: '在前端执行路由跳转',
|
||||
@@ -82,25 +44,6 @@ class ToolRegistry {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _handleCreateCalendarEvent(
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'ok': true,
|
||||
'message': '日程已创建',
|
||||
'title': args['title'],
|
||||
'description': args['description'],
|
||||
'startAt': args['startAt'],
|
||||
'endAt': args['endAt'],
|
||||
'timezone': args['timezone'] ?? _defaultTimezone,
|
||||
'location': args['location'],
|
||||
'color': _defaultEventColor,
|
||||
'sourceType': _defaultSourceType,
|
||||
};
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _handleNavigateRoute(
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/api/mock_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.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 {
|
||||
@@ -57,7 +57,14 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
|
||||
ChatBloc({AgUiService? service, IApiClient? apiClient})
|
||||
: _service =
|
||||
service ?? AgUiService(apiClient: apiClient ?? sl<IApiClient>()),
|
||||
service ??
|
||||
AgUiService(
|
||||
apiClient:
|
||||
apiClient ??
|
||||
(sl.isRegistered<IApiClient>()
|
||||
? sl<IApiClient>()
|
||||
: MockApiClient()),
|
||||
),
|
||||
super(const ChatState()) {
|
||||
_service.onEvent = _handleEvent;
|
||||
}
|
||||
@@ -162,13 +169,10 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
|
||||
final nextStatus = item.toolName == 'navigate_to_route'
|
||||
final nextStatus = item.toolName == 'front.navigate_to_route'
|
||||
? ToolCallStatus.pending
|
||||
: ToolCallStatus.executing;
|
||||
return item.copyWith(
|
||||
args: parsedArgs,
|
||||
status: nextStatus,
|
||||
);
|
||||
return item.copyWith(args: parsedArgs, status: nextStatus);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
@@ -344,7 +348,10 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.callId == toolCallId) {
|
||||
return item.copyWith(status: ToolCallStatus.executing, errorMessage: null);
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.executing,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
@@ -365,10 +372,20 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: failedItems, isLoading: false, error: error.toString()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: failedItems,
|
||||
isLoading: false,
|
||||
error: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> transcribeAudioFile(String filePath) {
|
||||
return _service.transcribeAudio(filePath);
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
emit(state.copyWith(error: null));
|
||||
}
|
||||
|
||||
@@ -4,14 +4,19 @@ import '../../data/models/tool_result.dart';
|
||||
|
||||
/// 卡片类型常量
|
||||
const _calendarCardType = 'calendar_card.v1';
|
||||
const _calendarListType = 'calendar_event_list.v1';
|
||||
const _calendarOperationType = 'calendar_operation.v1';
|
||||
const _errorCardType = 'error_card.v1';
|
||||
const _aiGeneratedSource = 'ai_generated';
|
||||
const _agentGeneratedSource = 'agent_generated';
|
||||
const _primaryActionType = 'primary';
|
||||
|
||||
class UiSchemaRenderer {
|
||||
static Widget render(UiCard card) {
|
||||
return switch (card.cardType) {
|
||||
_calendarCardType => _renderCalendarCard(card),
|
||||
_calendarListType => _renderCalendarList(card),
|
||||
_calendarOperationType => _renderCalendarOperation(card),
|
||||
_errorCardType => _renderErrorCard(card),
|
||||
_ => _renderUnknownCard(card),
|
||||
};
|
||||
@@ -22,7 +27,9 @@ class UiSchemaRenderer {
|
||||
final color = data.color != null
|
||||
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
||||
: AppColors.blue500;
|
||||
final isAiGenerated = data.sourceType == _aiGeneratedSource;
|
||||
final isAiGenerated =
|
||||
data.sourceType == _aiGeneratedSource ||
|
||||
data.sourceType == _agentGeneratedSource;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -152,6 +159,103 @@ class UiSchemaRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderCalendarList(UiCard card) {
|
||||
final rawItems = card.data['items'];
|
||||
final items = rawItems is List ? rawItems : const [];
|
||||
final paginationRaw = card.data['pagination'];
|
||||
final pagination = paginationRaw is Map<String, dynamic>
|
||||
? paginationRaw
|
||||
: const <String, dynamic>{};
|
||||
final page = pagination['page'];
|
||||
final total = pagination['total'];
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageCardBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.messageCardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'日程列表',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
if (page != null || total != null) ...[
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
'第${page ?? '-'}页 · 共${total ?? '-'}条',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
if (items.isEmpty)
|
||||
Text(
|
||||
'暂无日程',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
for (final item in items)
|
||||
if (item is Map<String, dynamic>)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
item['title']?.toString() ?? '未命名日程',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderCalendarOperation(UiCard card) {
|
||||
final ok = card.data['ok'] == true;
|
||||
final operation = card.data['operation']?.toString() ?? 'operation';
|
||||
final message = card.data['message']?.toString() ?? (ok ? '操作成功' : '操作失败');
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: ok ? AppColors.messageCardBg : AppColors.warningBackground,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: ok ? AppColors.messageCardBorder : AppColors.red400,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'日程$operation结果',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ok ? AppColors.slate900 : AppColors.red600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: ok ? AppColors.slate600 : AppColors.red600,
|
||||
),
|
||||
),
|
||||
if (card.actions != null && card.actions!.isNotEmpty) ...[
|
||||
SizedBox(height: AppSpacing.md),
|
||||
_buildActions(card.actions!),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderErrorCard(UiCard card) {
|
||||
final message = card.data['message'] as String? ?? '发生错误';
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
abstract class VoiceRecorder {
|
||||
Future<void> start();
|
||||
Future<String?> stop();
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
class RecordVoiceRecorder implements VoiceRecorder {
|
||||
final AudioRecorder _recorder;
|
||||
String? _currentPath;
|
||||
|
||||
RecordVoiceRecorder({AudioRecorder? recorder})
|
||||
: _recorder = recorder ?? AudioRecorder();
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
bool hasPermission;
|
||||
try {
|
||||
hasPermission = await _recorder.hasPermission();
|
||||
} on MissingPluginException catch (_) {
|
||||
throw StateError('录音组件未加载,请完全重启 App 后重试');
|
||||
}
|
||||
if (!hasPermission) {
|
||||
throw StateError('录音权限未授权');
|
||||
}
|
||||
|
||||
final fileName =
|
||||
'voice_${DateTime.now().millisecondsSinceEpoch}_${DateTime.now().microsecond}.wav';
|
||||
final path = '${Directory.systemTemp.path}/$fileName';
|
||||
_currentPath = path;
|
||||
try {
|
||||
await _recorder.start(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.wav,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
),
|
||||
path: path,
|
||||
);
|
||||
} on MissingPluginException catch (_) {
|
||||
throw StateError('录音组件未加载,请完全重启 App 后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> stop() async {
|
||||
String? stoppedPath;
|
||||
try {
|
||||
stoppedPath = await _recorder.stop();
|
||||
} on MissingPluginException catch (_) {
|
||||
throw StateError('录音组件未加载,请完全重启 App 后重试');
|
||||
}
|
||||
return stoppedPath ?? _currentPath;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _recorder.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/api/api_exception.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../chat/data/models/chat_list_item.dart';
|
||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||
import '../../../chat/data/tools/route_navigation_tool.dart';
|
||||
import '../../data/voice_recorder.dart';
|
||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
@@ -23,22 +27,42 @@ const _cornerRadius = 12.0;
|
||||
const _inputMinHeight = 48.0;
|
||||
const _inputRadius = 24.0;
|
||||
const _scrollDurationMs = 300;
|
||||
const _rippleDurationMs = 1200;
|
||||
const _recordingDotSize = 10.0;
|
||||
|
||||
/// 颜色常量
|
||||
const _chatBgColor = Color(0xFFF8FAFC);
|
||||
const _userBubbleColor = Color(0xFFEAF1FB);
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
final VoiceRecorder? voiceRecorder;
|
||||
final Future<String> Function(String filePath)? onTranscribeAudio;
|
||||
final Future<void> Function(String transcript)? onAutoSendTranscript;
|
||||
final bool autoLoadHistory;
|
||||
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
this.voiceRecorder,
|
||||
this.onTranscribeAudio,
|
||||
this.onAutoSendTranscript,
|
||||
this.autoLoadHistory = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeScreenState extends State<HomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final ChatBloc _chatBloc;
|
||||
late final VoiceRecorder _voiceRecorder;
|
||||
late final Future<String> Function(String filePath) _transcribeAudio;
|
||||
late final Future<void> Function(String transcript) _autoSendTranscript;
|
||||
late final AnimationController _listeningAnimationController;
|
||||
bool _isRecording = false;
|
||||
bool _isTranscribing = false;
|
||||
|
||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||
|
||||
@@ -47,7 +71,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
super.initState();
|
||||
_messageController.addListener(_onMessageChanged);
|
||||
_chatBloc = ChatBloc();
|
||||
_chatBloc.loadHistory();
|
||||
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
|
||||
_transcribeAudio =
|
||||
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
|
||||
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
|
||||
_listeningAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||
);
|
||||
if (widget.autoLoadHistory) {
|
||||
_chatBloc.loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -55,6 +89,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_messageController.removeListener(_onMessageChanged);
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_listeningAnimationController.dispose();
|
||||
_voiceRecorder.dispose();
|
||||
_chatBloc.close();
|
||||
RouteNavigationTool.instance.clearNavigator();
|
||||
super.dispose();
|
||||
@@ -341,7 +377,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
|
||||
if (item.toolName == 'navigate_to_route' &&
|
||||
if (item.toolName == 'front.navigate_to_route' &&
|
||||
item.status == ToolCallStatus.pending) ...[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
@@ -376,7 +412,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _showBottomSheet(context),
|
||||
onTap: _isRecording
|
||||
? _stopRecording
|
||||
: () => _showBottomSheet(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -385,10 +423,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.plus,
|
||||
child: Icon(
|
||||
_isRecording ? LucideIcons.square : LucideIcons.plus,
|
||||
size: 20,
|
||||
color: AppColors.slate500,
|
||||
color: _isRecording ? AppColors.red600 : AppColors.slate500,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -406,32 +444,42 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(context),
|
||||
),
|
||||
child: _isRecording
|
||||
? _buildListeningIndicator()
|
||||
: TextField(
|
||||
controller: _messageController,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _hasMessage ? () => _sendMessage(context) : null,
|
||||
onTap: _isTranscribing
|
||||
? null
|
||||
: _isRecording
|
||||
? () => _stopRecording(autoSendAfterTranscribe: true)
|
||||
: _hasMessage
|
||||
? () => _sendMessage(context)
|
||||
: _startRecording,
|
||||
child: Icon(
|
||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
||||
_isRecording || _hasMessage
|
||||
? LucideIcons.send
|
||||
: LucideIcons.mic,
|
||||
size: _iconSize,
|
||||
color: _hasMessage
|
||||
color: _isRecording || _hasMessage
|
||||
? AppColors.blue600
|
||||
: AppColors.slate500,
|
||||
),
|
||||
@@ -462,6 +510,134 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildListeningIndicator() {
|
||||
return SizedBox(
|
||||
height: _inputMinHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _listeningAnimationController,
|
||||
builder: (context, _) {
|
||||
final t = _listeningAnimationController.value;
|
||||
final waveA =
|
||||
0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0));
|
||||
final waveB =
|
||||
0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0));
|
||||
final waveC =
|
||||
0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0));
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildWaveDot(scale: waveA),
|
||||
const SizedBox(width: 6),
|
||||
_buildWaveDot(scale: waveB),
|
||||
const SizedBox(width: 6),
|
||||
_buildWaveDot(scale: waveC),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'正在聆听...',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveDot({required double scale}) {
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Container(
|
||||
width: _recordingDotSize,
|
||||
height: _recordingDotSize,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.red600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
try {
|
||||
await _voiceRecorder.start();
|
||||
_listeningAnimationController.repeat();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(context, _readableError(error), type: ToastType.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording({bool autoSendAfterTranscribe = false}) async {
|
||||
String? audioPath;
|
||||
try {
|
||||
audioPath = await _voiceRecorder.stop();
|
||||
_listeningAnimationController.stop();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
_isTranscribing = true;
|
||||
});
|
||||
if (audioPath == null || audioPath.isEmpty) {
|
||||
throw StateError('录音失败,请重试');
|
||||
}
|
||||
final transcript = await _transcribeAudio(audioPath);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_messageController.text = transcript;
|
||||
_messageController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: transcript.length),
|
||||
);
|
||||
if (autoSendAfterTranscribe && transcript.trim().isNotEmpty) {
|
||||
_messageController.clear();
|
||||
await _autoSendTranscript(transcript.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(context, _readableError(error), type: ToastType.error);
|
||||
} finally {
|
||||
if (audioPath != null) {
|
||||
final file = File(audioPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isTranscribing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _readableError(Object error) {
|
||||
if (error is ApiException) {
|
||||
return error.message;
|
||||
}
|
||||
final raw = error.toString();
|
||||
if (raw.startsWith('Instance of')) {
|
||||
return '请求失败,请稍后重试';
|
||||
}
|
||||
return raw.replaceFirst('Bad state: ', '');
|
||||
}
|
||||
|
||||
void _showBottomSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
Reference in New Issue
Block a user