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:
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="social_app"
|
android:label="social_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ class AiDecisionEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ForceTriggerResult? tryForceTrigger(String text) {
|
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;
|
if (match == null) return null;
|
||||||
|
|
||||||
final toolName = match.group(1)!;
|
final toolName = match.group(1)!;
|
||||||
|
|||||||
@@ -297,6 +297,14 @@ class ToolCallResultEvent extends AgUiEvent {
|
|||||||
if (rawUi is Map<String, dynamic>) {
|
if (rawUi is Map<String, dynamic>) {
|
||||||
return UiCard.fromJson(rawUi);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:social_app/core/api/i_api_client.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/api/mock_api_client.dart';
|
||||||
|
|
||||||
import '../ai/ai_decision_engine.dart';
|
import '../ai/ai_decision_engine.dart';
|
||||||
import '../models/ag_ui_event.dart';
|
import '../models/ag_ui_event.dart';
|
||||||
import '../models/tool_result.dart';
|
|
||||||
import '../tools/tool_registry.dart';
|
import '../tools/tool_registry.dart';
|
||||||
import 'mock_history_service.dart';
|
import 'mock_history_service.dart';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class AgUiService {
|
|||||||
_decisionEngine = AiDecisionEngine(),
|
_decisionEngine = AiDecisionEngine(),
|
||||||
_historyService = MockHistoryService() {
|
_historyService = MockHistoryService() {
|
||||||
if (_apiClient is MockApiClient) {
|
if (_apiClient is MockApiClient) {
|
||||||
_configureMockAgentApi(_apiClient as MockApiClient);
|
_configureMockAgentApi(_apiClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +77,28 @@ class AgUiService {
|
|||||||
onEvent(event);
|
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({
|
Future<void> approveToolCall({
|
||||||
required String toolCallId,
|
required String toolCallId,
|
||||||
required String toolName,
|
required String toolName,
|
||||||
@@ -210,11 +232,7 @@ class AgUiService {
|
|||||||
'runId': runId,
|
'runId': runId,
|
||||||
'state': <String, dynamic>{},
|
'state': <String, dynamic>{},
|
||||||
'messages': [
|
'messages': [
|
||||||
{
|
{'id': _nextId('user_'), 'role': 'user', 'content': content},
|
||||||
'id': _nextId('user_'),
|
|
||||||
'role': 'user',
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
'tools': _buildTools(),
|
'tools': _buildTools(),
|
||||||
'context': <Map<String, dynamic>>[],
|
'context': <Map<String, dynamic>>[],
|
||||||
@@ -225,33 +243,20 @@ class AgUiService {
|
|||||||
List<Map<String, dynamic>> _buildTools() {
|
List<Map<String, dynamic>> _buildTools() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'name': 'navigate_to_route',
|
'name': 'front.navigate_to_route',
|
||||||
'description': 'Navigate user to a route in the mobile app.',
|
'description': 'Navigate user to a route in the mobile app.',
|
||||||
'parameters': {
|
'parameters': {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
'target': {'type': 'string', 'description': 'Route path target'},
|
'target': {'type': 'string', 'description': 'Route path target'},
|
||||||
'replace': {'type': 'boolean', 'description': 'Use replace navigation'},
|
'replace': {
|
||||||
|
'type': 'boolean',
|
||||||
|
'description': 'Use replace navigation',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'required': ['target'],
|
'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('&')}';
|
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() {
|
String _newUuid() {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
@@ -304,6 +310,15 @@ class AgUiService {
|
|||||||
'SSE',
|
'SSE',
|
||||||
_handleMockSse,
|
_handleMockSse,
|
||||||
);
|
);
|
||||||
|
client.registerHandler(
|
||||||
|
'/api/v1/agent/transcribe',
|
||||||
|
'POST',
|
||||||
|
_handleMockTranscribe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
|
||||||
|
return {'transcript': '这是模拟语音转写'};
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _handleMockRun(MockRequest request) {
|
Map<String, dynamic> _handleMockRun(MockRequest request) {
|
||||||
@@ -331,9 +346,9 @@ class AgUiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _handleMockResume(MockRequest request) {
|
Map<String, dynamic> _handleMockResume(MockRequest request) {
|
||||||
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/resume$').firstMatch(
|
final match = RegExp(
|
||||||
request.path,
|
r'^/api/v1/agent/runs/([^/]+)/resume$',
|
||||||
);
|
).firstMatch(request.path);
|
||||||
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
|
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
|
||||||
final payload = request.data;
|
final payload = request.data;
|
||||||
final runInput = payload is Map<String, dynamic>
|
final runInput = payload is Map<String, dynamic>
|
||||||
@@ -344,7 +359,11 @@ class AgUiService {
|
|||||||
|
|
||||||
final toolMessage = _extractLatestToolMessage(runInput);
|
final toolMessage = _extractLatestToolMessage(runInput);
|
||||||
final events = <Map<String, dynamic>>[
|
final events = <Map<String, dynamic>>[
|
||||||
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId},
|
{
|
||||||
|
'type': AgUiEventTypeWire.runStarted,
|
||||||
|
'threadId': threadId,
|
||||||
|
'runId': runId,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'type': AgUiEventTypeWire.toolCallResult,
|
'type': AgUiEventTypeWire.toolCallResult,
|
||||||
'messageId': _nextId(_messageIdPrefix),
|
'messageId': _nextId(_messageIdPrefix),
|
||||||
@@ -365,7 +384,11 @@ class AgUiService {
|
|||||||
'type': AgUiEventTypeWire.textMessageEnd,
|
'type': AgUiEventTypeWire.textMessageEnd,
|
||||||
'messageId': _nextId(_messageIdPrefix),
|
'messageId': _nextId(_messageIdPrefix),
|
||||||
},
|
},
|
||||||
{'type': AgUiEventTypeWire.runFinished, 'threadId': threadId, 'runId': runId},
|
{
|
||||||
|
'type': AgUiEventTypeWire.runFinished,
|
||||||
|
'threadId': threadId,
|
||||||
|
'runId': runId,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
||||||
return {
|
return {
|
||||||
@@ -398,7 +421,8 @@ class AgUiService {
|
|||||||
final messages = targetDate == null
|
final messages = targetDate == null
|
||||||
? <SnapshotMessage>[]
|
? <SnapshotMessage>[]
|
||||||
: _historyService.getHistoryForDay(targetDate);
|
: _historyService.getHistoryForDay(targetDate);
|
||||||
final hasMore = targetDate != null && _historyService.hasEarlierHistory(targetDate);
|
final hasMore =
|
||||||
|
targetDate != null && _historyService.hasEarlierHistory(targetDate);
|
||||||
_hasMoreHistory = hasMore;
|
_hasMoreHistory = hasMore;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -421,9 +445,9 @@ class AgUiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Stream<String> _handleMockSse(MockRequest request) {
|
Stream<String> _handleMockSse(MockRequest request) {
|
||||||
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/events$').firstMatch(
|
final match = RegExp(
|
||||||
request.path,
|
r'^/api/v1/agent/runs/([^/]+)/events$',
|
||||||
);
|
).firstMatch(request.path);
|
||||||
final threadId = match?.group(1);
|
final threadId = match?.group(1);
|
||||||
if (threadId == null) {
|
if (threadId == null) {
|
||||||
return const Stream<String>.empty();
|
return const Stream<String>.empty();
|
||||||
@@ -441,7 +465,11 @@ class AgUiService {
|
|||||||
required String userInput,
|
required String userInput,
|
||||||
}) {
|
}) {
|
||||||
final events = <Map<String, dynamic>>[
|
final events = <Map<String, dynamic>>[
|
||||||
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId},
|
{
|
||||||
|
'type': AgUiEventTypeWire.runStarted,
|
||||||
|
'threadId': threadId,
|
||||||
|
'runId': runId,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
|
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
|
||||||
@@ -451,19 +479,13 @@ class AgUiService {
|
|||||||
toolName = forceTrigger.toolName;
|
toolName = forceTrigger.toolName;
|
||||||
args = forceTrigger.args;
|
args = forceTrigger.args;
|
||||||
} else if (_looksLikeNavigationIntent(userInput)) {
|
} else if (_looksLikeNavigationIntent(userInput)) {
|
||||||
toolName = 'navigate_to_route';
|
toolName = 'front.navigate_to_route';
|
||||||
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
|
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 != null && args != null) {
|
||||||
if (toolName == 'navigate_to_route') {
|
if (toolName == 'front.navigate_to_route') {
|
||||||
args = {
|
args = {...args, '__nonce': _nextId('nonce_')};
|
||||||
...args,
|
|
||||||
'__nonce': _nextId('nonce_'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
final toolCallId = _nextId(_toolCallIdPrefix);
|
final toolCallId = _nextId(_toolCallIdPrefix);
|
||||||
events.add({
|
events.add({
|
||||||
@@ -476,32 +498,20 @@ class AgUiService {
|
|||||||
'toolCallId': toolCallId,
|
'toolCallId': toolCallId,
|
||||||
'delta': jsonEncode(args),
|
'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。
|
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
|
||||||
} else {
|
} else {
|
||||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
events.add({
|
||||||
if (!validation.ok) {
|
'type': AgUiEventTypeWire.toolCallError,
|
||||||
events.add({
|
'toolCallId': toolCallId,
|
||||||
'type': AgUiEventTypeWire.toolCallError,
|
'error': 'Unsupported frontend tool in mock mode',
|
||||||
'toolCallId': toolCallId,
|
'code': 'UNSUPPORTED_TOOL',
|
||||||
'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(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,7 +528,10 @@ class AgUiService {
|
|||||||
'messageId': messageId,
|
'messageId': messageId,
|
||||||
'delta': reply,
|
'delta': reply,
|
||||||
});
|
});
|
||||||
events.add({'type': AgUiEventTypeWire.textMessageEnd, 'messageId': messageId});
|
events.add({
|
||||||
|
'type': AgUiEventTypeWire.textMessageEnd,
|
||||||
|
'messageId': messageId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
events.add({
|
events.add({
|
||||||
@@ -577,57 +590,14 @@ class AgUiService {
|
|||||||
if (raw['role'] != 'tool') {
|
if (raw['role'] != 'tool') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final toolCallId = raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
|
final toolCallId =
|
||||||
|
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
|
||||||
final content = raw['content'] as String? ?? '{}';
|
final content = raw['content'] as String? ?? '{}';
|
||||||
return (toolCallId, content);
|
return (toolCallId, content);
|
||||||
}
|
}
|
||||||
return (_nextId(_toolCallIdPrefix), '{}');
|
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) {
|
List<String> _generateReplies(String content) {
|
||||||
final intent = _decisionEngine.matchIntent(content);
|
final intent = _decisionEngine.matchIntent(content);
|
||||||
switch (intent) {
|
switch (intent) {
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ typedef ToolHandler =
|
|||||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
|
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
|
||||||
|
|
||||||
/// 工具常量
|
/// 工具常量
|
||||||
const _toolNameCreateCalendar = 'create_calendar_event';
|
const _toolNameNavigateRoute = 'front.navigate_to_route';
|
||||||
const _toolNameNavigateRoute = 'navigate_to_route';
|
|
||||||
const _defaultTimezone = 'Asia/Shanghai';
|
|
||||||
const _defaultEventColor = '#4F46E5';
|
|
||||||
const _defaultSourceType = 'agentGenerated';
|
|
||||||
const _titleMinLength = 1;
|
|
||||||
const _titleMaxLength = 100;
|
|
||||||
|
|
||||||
class ToolDefinition {
|
class ToolDefinition {
|
||||||
final String name;
|
final String name;
|
||||||
@@ -33,38 +27,6 @@ class ToolRegistry {
|
|||||||
static void initialize() {
|
static void initialize() {
|
||||||
if (_initialized) return;
|
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(
|
_tools[_toolNameNavigateRoute] = ToolDefinition(
|
||||||
name: _toolNameNavigateRoute,
|
name: _toolNameNavigateRoute,
|
||||||
description: '在前端执行路由跳转',
|
description: '在前端执行路由跳转',
|
||||||
@@ -82,25 +44,6 @@ class ToolRegistry {
|
|||||||
_initialized = true;
|
_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(
|
static Future<Map<String, dynamic>> _handleNavigateRoute(
|
||||||
Map<String, dynamic> args,
|
Map<String, dynamic> args,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:social_app/core/api/i_api_client.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 'package:social_app/core/di/injection.dart';
|
||||||
|
|
||||||
import '../../data/models/ag_ui_event.dart';
|
import '../../data/models/ag_ui_event.dart';
|
||||||
import '../../data/models/chat_list_item.dart';
|
import '../../data/models/chat_list_item.dart';
|
||||||
import '../../data/models/tool_result.dart';
|
|
||||||
import '../../data/services/ag_ui_service.dart';
|
import '../../data/services/ag_ui_service.dart';
|
||||||
|
|
||||||
class ChatState {
|
class ChatState {
|
||||||
@@ -57,7 +57,14 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
|
|
||||||
ChatBloc({AgUiService? service, IApiClient? apiClient})
|
ChatBloc({AgUiService? service, IApiClient? apiClient})
|
||||||
: _service =
|
: _service =
|
||||||
service ?? AgUiService(apiClient: apiClient ?? sl<IApiClient>()),
|
service ??
|
||||||
|
AgUiService(
|
||||||
|
apiClient:
|
||||||
|
apiClient ??
|
||||||
|
(sl.isRegistered<IApiClient>()
|
||||||
|
? sl<IApiClient>()
|
||||||
|
: MockApiClient()),
|
||||||
|
),
|
||||||
super(const ChatState()) {
|
super(const ChatState()) {
|
||||||
_service.onEvent = _handleEvent;
|
_service.onEvent = _handleEvent;
|
||||||
}
|
}
|
||||||
@@ -162,13 +169,10 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
||||||
final updatedItems = state.items.map((item) {
|
final updatedItems = state.items.map((item) {
|
||||||
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
|
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.pending
|
||||||
: ToolCallStatus.executing;
|
: ToolCallStatus.executing;
|
||||||
return item.copyWith(
|
return item.copyWith(args: parsedArgs, status: nextStatus);
|
||||||
args: parsedArgs,
|
|
||||||
status: nextStatus,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -344,7 +348,10 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
}
|
}
|
||||||
final updatedItems = state.items.map((item) {
|
final updatedItems = state.items.map((item) {
|
||||||
if (item is ToolCallItem && item.callId == toolCallId) {
|
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;
|
return item;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -365,10 +372,20 @@ class ChatBloc extends Cubit<ChatState> {
|
|||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}).toList();
|
}).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() {
|
void clearError() {
|
||||||
emit(state.copyWith(error: null));
|
emit(state.copyWith(error: null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,19 @@ import '../../data/models/tool_result.dart';
|
|||||||
|
|
||||||
/// 卡片类型常量
|
/// 卡片类型常量
|
||||||
const _calendarCardType = 'calendar_card.v1';
|
const _calendarCardType = 'calendar_card.v1';
|
||||||
|
const _calendarListType = 'calendar_event_list.v1';
|
||||||
|
const _calendarOperationType = 'calendar_operation.v1';
|
||||||
const _errorCardType = 'error_card.v1';
|
const _errorCardType = 'error_card.v1';
|
||||||
const _aiGeneratedSource = 'ai_generated';
|
const _aiGeneratedSource = 'ai_generated';
|
||||||
|
const _agentGeneratedSource = 'agent_generated';
|
||||||
const _primaryActionType = 'primary';
|
const _primaryActionType = 'primary';
|
||||||
|
|
||||||
class UiSchemaRenderer {
|
class UiSchemaRenderer {
|
||||||
static Widget render(UiCard card) {
|
static Widget render(UiCard card) {
|
||||||
return switch (card.cardType) {
|
return switch (card.cardType) {
|
||||||
_calendarCardType => _renderCalendarCard(card),
|
_calendarCardType => _renderCalendarCard(card),
|
||||||
|
_calendarListType => _renderCalendarList(card),
|
||||||
|
_calendarOperationType => _renderCalendarOperation(card),
|
||||||
_errorCardType => _renderErrorCard(card),
|
_errorCardType => _renderErrorCard(card),
|
||||||
_ => _renderUnknownCard(card),
|
_ => _renderUnknownCard(card),
|
||||||
};
|
};
|
||||||
@@ -22,7 +27,9 @@ class UiSchemaRenderer {
|
|||||||
final color = data.color != null
|
final color = data.color != null
|
||||||
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
||||||
: AppColors.blue500;
|
: AppColors.blue500;
|
||||||
final isAiGenerated = data.sourceType == _aiGeneratedSource;
|
final isAiGenerated =
|
||||||
|
data.sourceType == _aiGeneratedSource ||
|
||||||
|
data.sourceType == _agentGeneratedSource;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
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) {
|
static Widget _renderErrorCard(UiCard card) {
|
||||||
final message = card.data['message'] as String? ?? '发生错误';
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../../core/api/api_exception.dart';
|
||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../chat/data/models/chat_list_item.dart';
|
import '../../../chat/data/models/chat_list_item.dart';
|
||||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||||
import '../../../chat/data/tools/route_navigation_tool.dart';
|
import '../../../chat/data/tools/route_navigation_tool.dart';
|
||||||
|
import '../../data/voice_recorder.dart';
|
||||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||||
import '../../../../shared/widgets/toast/toast.dart';
|
import '../../../../shared/widgets/toast/toast.dart';
|
||||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||||
@@ -23,22 +27,42 @@ const _cornerRadius = 12.0;
|
|||||||
const _inputMinHeight = 48.0;
|
const _inputMinHeight = 48.0;
|
||||||
const _inputRadius = 24.0;
|
const _inputRadius = 24.0;
|
||||||
const _scrollDurationMs = 300;
|
const _scrollDurationMs = 300;
|
||||||
|
const _rippleDurationMs = 1200;
|
||||||
|
const _recordingDotSize = 10.0;
|
||||||
|
|
||||||
/// 颜色常量
|
/// 颜色常量
|
||||||
const _chatBgColor = Color(0xFFF8FAFC);
|
const _chatBgColor = Color(0xFFF8FAFC);
|
||||||
const _userBubbleColor = Color(0xFFEAF1FB);
|
const _userBubbleColor = Color(0xFFEAF1FB);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
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
|
@override
|
||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
final TextEditingController _messageController = TextEditingController();
|
final TextEditingController _messageController = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
late final ChatBloc _chatBloc;
|
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;
|
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
@@ -47,7 +71,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_messageController.addListener(_onMessageChanged);
|
_messageController.addListener(_onMessageChanged);
|
||||||
_chatBloc = ChatBloc();
|
_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
|
@override
|
||||||
@@ -55,6 +89,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_messageController.removeListener(_onMessageChanged);
|
_messageController.removeListener(_onMessageChanged);
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_listeningAnimationController.dispose();
|
||||||
|
_voiceRecorder.dispose();
|
||||||
_chatBloc.close();
|
_chatBloc.close();
|
||||||
RouteNavigationTool.instance.clearNavigator();
|
RouteNavigationTool.instance.clearNavigator();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -341,7 +377,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
|
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) ...[
|
item.status == ToolCallStatus.pending) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -376,7 +412,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _showBottomSheet(context),
|
onTap: _isRecording
|
||||||
|
? _stopRecording
|
||||||
|
: () => _showBottomSheet(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -385,10 +423,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: AppColors.slate300),
|
border: Border.all(color: AppColors.slate300),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
LucideIcons.plus,
|
_isRecording ? LucideIcons.square : LucideIcons.plus,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: AppColors.slate500,
|
color: _isRecording ? AppColors.red600 : AppColors.slate500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -406,32 +444,42 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: _isRecording
|
||||||
controller: _messageController,
|
? _buildListeningIndicator()
|
||||||
minLines: 1,
|
: TextField(
|
||||||
maxLines: 3,
|
controller: _messageController,
|
||||||
decoration: const InputDecoration(
|
minLines: 1,
|
||||||
hintText: '输入消息...',
|
maxLines: 3,
|
||||||
border: InputBorder.none,
|
decoration: const InputDecoration(
|
||||||
enabledBorder: InputBorder.none,
|
hintText: '输入消息...',
|
||||||
focusedBorder: InputBorder.none,
|
border: InputBorder.none,
|
||||||
disabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
errorBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
focusedErrorBorder: InputBorder.none,
|
disabledBorder: InputBorder.none,
|
||||||
isDense: true,
|
errorBorder: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.zero,
|
focusedErrorBorder: InputBorder.none,
|
||||||
filled: false,
|
isDense: true,
|
||||||
),
|
contentPadding: EdgeInsets.zero,
|
||||||
onSubmitted: (_) => _sendMessage(context),
|
filled: false,
|
||||||
),
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _hasMessage ? () => _sendMessage(context) : null,
|
onTap: _isTranscribing
|
||||||
|
? null
|
||||||
|
: _isRecording
|
||||||
|
? () => _stopRecording(autoSendAfterTranscribe: true)
|
||||||
|
: _hasMessage
|
||||||
|
? () => _sendMessage(context)
|
||||||
|
: _startRecording,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
_isRecording || _hasMessage
|
||||||
|
? LucideIcons.send
|
||||||
|
: LucideIcons.mic,
|
||||||
size: _iconSize,
|
size: _iconSize,
|
||||||
color: _hasMessage
|
color: _isRecording || _hasMessage
|
||||||
? AppColors.blue600
|
? AppColors.blue600
|
||||||
: AppColors.slate500,
|
: 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) {
|
void _showBottomSheet(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ dependencies:
|
|||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
json_annotation: ^4.8.1
|
json_annotation: ^4.8.1
|
||||||
|
record: ^6.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ void main() {
|
|||||||
final json = {
|
final json = {
|
||||||
'type': 'TOOL_CALL_START',
|
'type': 'TOOL_CALL_START',
|
||||||
'toolCallId': 'tc_123',
|
'toolCallId': 'tc_123',
|
||||||
'toolCallName': 'create_calendar_event',
|
'toolCallName': 'back.mutate_calendar_event',
|
||||||
'parentMessageId': 'msg_001',
|
'parentMessageId': 'msg_001',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ void main() {
|
|||||||
expect(event, isA<ToolCallStartEvent>());
|
expect(event, isA<ToolCallStartEvent>());
|
||||||
final toolStart = event as ToolCallStartEvent;
|
final toolStart = event as ToolCallStartEvent;
|
||||||
expect(toolStart.toolCallId, 'tc_123');
|
expect(toolStart.toolCallId, 'tc_123');
|
||||||
expect(toolStart.toolCallName, 'create_calendar_event');
|
expect(toolStart.toolCallName, 'back.mutate_calendar_event');
|
||||||
expect(toolStart.parentMessageId, 'msg_001');
|
expect(toolStart.parentMessageId, 'msg_001');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,6 +265,37 @@ void main() {
|
|||||||
expect(toolResult.result['eventId'], 'evt_001');
|
expect(toolResult.result['eventId'], 'evt_001');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ToolCallResultEvent.ui parses from payload.ui', () {
|
||||||
|
final json = {
|
||||||
|
'type': 'TOOL_CALL_RESULT',
|
||||||
|
'messageId': 'msg_123',
|
||||||
|
'toolCallId': 'tc_123',
|
||||||
|
'content':
|
||||||
|
'{"ui":{"type":"calendar_card.v1","version":"v1","data":{"id":"evt_1","title":"会议","startAt":"2026-03-01T10:00:00Z"},"actions":[]}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
final event = AgUiEvent.fromJson(json) as ToolCallResultEvent;
|
||||||
|
expect(event.ui, isNotNull);
|
||||||
|
expect(event.ui!.cardType, 'calendar_card.v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ToolCallResultEvent.ui parses from payload.result when result is UiCard',
|
||||||
|
() {
|
||||||
|
final json = {
|
||||||
|
'type': 'TOOL_CALL_RESULT',
|
||||||
|
'messageId': 'msg_123',
|
||||||
|
'toolCallId': 'tc_123',
|
||||||
|
'content':
|
||||||
|
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true},"actions":[]}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
final event = AgUiEvent.fromJson(json) as ToolCallResultEvent;
|
||||||
|
expect(event.ui, isNotNull);
|
||||||
|
expect(event.ui!.cardType, 'calendar_operation.v1');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('parses ToolCallErrorEvent', () {
|
test('parses ToolCallErrorEvent', () {
|
||||||
final json = {
|
final json = {
|
||||||
'type': 'TOOL_CALL_ERROR',
|
'type': 'TOOL_CALL_ERROR',
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ class TestableAgUiService extends AgUiService {
|
|||||||
final forceTrigger = engine.tryForceTrigger(content);
|
final forceTrigger = engine.tryForceTrigger(content);
|
||||||
if (forceTrigger != null) {
|
if (forceTrigger != null) {
|
||||||
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
||||||
} else if (engine.shouldTriggerToolCall(content)) {
|
|
||||||
await mockToolCallFlow(content, engine);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final replies = generateReplies(content, engine);
|
final replies = generateReplies(content, engine);
|
||||||
@@ -38,13 +36,6 @@ class TestableAgUiService extends AgUiService {
|
|||||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> mockToolCallFlow(String content, AiDecisionEngine engine) async {
|
|
||||||
final args = engine.getToolCallArgs(content);
|
|
||||||
if (args == null) return;
|
|
||||||
|
|
||||||
await mockToolCallFlowWithArgs('create_calendar_event', args);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> mockToolCallFlowWithArgs(
|
Future<void> mockToolCallFlowWithArgs(
|
||||||
String toolName,
|
String toolName,
|
||||||
Map<String, dynamic> args,
|
Map<String, dynamic> args,
|
||||||
@@ -57,6 +48,10 @@ class TestableAgUiService extends AgUiService {
|
|||||||
|
|
||||||
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
||||||
|
|
||||||
|
if (toolName == 'front.navigate_to_route') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
onEvent(
|
onEvent(
|
||||||
@@ -71,7 +66,7 @@ class TestableAgUiService extends AgUiService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ToolRegistry.initialize();
|
ToolRegistry.initialize();
|
||||||
final result = await ToolRegistry.execute(toolName, args);
|
await ToolRegistry.execute(toolName, args);
|
||||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
onEvent(
|
onEvent(
|
||||||
@@ -157,28 +152,30 @@ void main() {
|
|||||||
expect(types.last, AgUiEventType.runFinished);
|
expect(types.last, AgUiEventType.runFinished);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creating schedule text triggers tool call events', () async {
|
test(
|
||||||
await service.sendMessage('提醒我明天10点开会');
|
'creating schedule text does not trigger frontend tool call events',
|
||||||
|
() async {
|
||||||
|
await service.sendMessage('提醒我明天10点开会');
|
||||||
|
|
||||||
final toolCallStarts = capturedEvents
|
final toolCallStarts = capturedEvents
|
||||||
.whereType<ToolCallStartEvent>()
|
.whereType<ToolCallStartEvent>()
|
||||||
.toList();
|
.toList();
|
||||||
final toolCallEnds = capturedEvents
|
final toolCallEnds = capturedEvents
|
||||||
.whereType<ToolCallEndEvent>()
|
.whereType<ToolCallEndEvent>()
|
||||||
.toList();
|
.toList();
|
||||||
final toolCallResults = capturedEvents
|
final toolCallResults = capturedEvents
|
||||||
.whereType<ToolCallResultEvent>()
|
.whereType<ToolCallResultEvent>()
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
expect(toolCallStarts.isNotEmpty, true);
|
expect(toolCallStarts.isEmpty, true);
|
||||||
expect(toolCallEnds.isNotEmpty, true);
|
expect(toolCallEnds.isEmpty, true);
|
||||||
expect(toolCallResults.isNotEmpty, true);
|
expect(toolCallResults.isEmpty, true);
|
||||||
expect(toolCallStarts.first.toolCallName, 'create_calendar_event');
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
test('force trigger with #tool syntax', () async {
|
test('force trigger with #tool syntax', () async {
|
||||||
await service.sendMessage(
|
await service.sendMessage(
|
||||||
'#tool:create_calendar_event {"title": "Test", "startAt": "2026-03-01T10:00:00Z"}',
|
'#tool:front.navigate_to_route {"target": "/calendar/dayweek"}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final toolCallStarts = capturedEvents
|
final toolCallStarts = capturedEvents
|
||||||
@@ -186,7 +183,7 @@ void main() {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
expect(toolCallStarts.isNotEmpty, true);
|
expect(toolCallStarts.isNotEmpty, true);
|
||||||
expect(toolCallStarts.first.toolCallName, 'create_calendar_event');
|
expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('text message events are emitted for unknown intent', () async {
|
test('text message events are emitted for unknown intent', () async {
|
||||||
@@ -215,15 +212,18 @@ void main() {
|
|||||||
expect(toolCallStarts.isEmpty, true);
|
expect(toolCallStarts.isEmpty, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tool call with invalid args emits error', () async {
|
test('frontend tool call keeps pending state before approval', () async {
|
||||||
await service.sendMessage('#tool:create_calendar_event {}');
|
await service.sendMessage('#tool:front.navigate_to_route {}');
|
||||||
|
|
||||||
final toolCallErrors = capturedEvents
|
final toolCallErrors = capturedEvents
|
||||||
.whereType<ToolCallErrorEvent>()
|
.whereType<ToolCallErrorEvent>()
|
||||||
.toList();
|
.toList();
|
||||||
|
final toolCallStarts = capturedEvents
|
||||||
|
.whereType<ToolCallStartEvent>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
expect(toolCallErrors.isNotEmpty, true);
|
expect(toolCallStarts.isNotEmpty, true);
|
||||||
expect(toolCallErrors.first.error, contains('Missing required fields'));
|
expect(toolCallErrors.isEmpty, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ void main() {
|
|||||||
await service.sendMessage('初始化会话');
|
await service.sendMessage('初始化会话');
|
||||||
await service.approveToolCall(
|
await service.approveToolCall(
|
||||||
toolCallId: 'call-1',
|
toolCallId: 'call-1',
|
||||||
toolName: 'navigate_to_route',
|
toolName: 'front.navigate_to_route',
|
||||||
args: {
|
args: {
|
||||||
'target': '/calendar/dayweek',
|
'target': '/calendar/dayweek',
|
||||||
'replace': false,
|
'replace': false,
|
||||||
@@ -349,7 +349,7 @@ void main() {
|
|||||||
(e) => e.toolCallId == toolStart.toolCallId,
|
(e) => e.toolCallId == toolStart.toolCallId,
|
||||||
);
|
);
|
||||||
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
|
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
|
||||||
expect(toolStart.toolCallName, 'navigate_to_route');
|
expect(toolStart.toolCallName, 'front.navigate_to_route');
|
||||||
expect(
|
expect(
|
||||||
events
|
events
|
||||||
.whereType<ToolCallResultEvent>()
|
.whereType<ToolCallResultEvent>()
|
||||||
@@ -360,7 +360,7 @@ void main() {
|
|||||||
|
|
||||||
await realService.approveToolCall(
|
await realService.approveToolCall(
|
||||||
toolCallId: toolStart.toolCallId,
|
toolCallId: toolStart.toolCallId,
|
||||||
toolName: 'navigate_to_route',
|
toolName: 'front.navigate_to_route',
|
||||||
args: toolArgs,
|
args: toolArgs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -387,7 +387,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
() => realService.approveToolCall(
|
() => realService.approveToolCall(
|
||||||
toolCallId: toolStart.toolCallId,
|
toolCallId: toolStart.toolCallId,
|
||||||
toolName: 'navigate_to_route',
|
toolName: 'front.navigate_to_route',
|
||||||
args: toolArgs,
|
args: toolArgs,
|
||||||
),
|
),
|
||||||
throwsA(isA<StateError>()),
|
throwsA(isA<StateError>()),
|
||||||
|
|||||||
@@ -112,13 +112,18 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('tryForceTrigger', () {
|
group('tryForceTrigger', () {
|
||||||
test('returns ForceTriggerResult for "#tool:create_calendar_event {}"', () {
|
test(
|
||||||
final result = engine.tryForceTrigger('#tool:create_calendar_event {}');
|
'returns ForceTriggerResult for "#tool:front.navigate_to_route {}"',
|
||||||
|
() {
|
||||||
|
final result = engine.tryForceTrigger(
|
||||||
|
'#tool:front.navigate_to_route {}',
|
||||||
|
);
|
||||||
|
|
||||||
expect(result, isNotNull);
|
expect(result, isNotNull);
|
||||||
expect(result!.toolName, 'create_calendar_event');
|
expect(result!.toolName, 'front.navigate_to_route');
|
||||||
expect(result.args, isEmpty);
|
expect(result.args, isEmpty);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
|
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ void main() {
|
|||||||
service.onEvent(
|
service.onEvent(
|
||||||
ToolCallStartEvent(
|
ToolCallStartEvent(
|
||||||
toolCallId: 'tc_1',
|
toolCallId: 'tc_1',
|
||||||
toolCallName: 'create_calendar_event',
|
toolCallName: 'back.mutate_calendar_event',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -203,7 +203,7 @@ void main() {
|
|||||||
(s) {
|
(s) {
|
||||||
final item = s.items.first;
|
final item = s.items.first;
|
||||||
return item is ToolCallItem &&
|
return item is ToolCallItem &&
|
||||||
item.toolName == 'create_calendar_event' &&
|
item.toolName == 'back.mutate_calendar_event' &&
|
||||||
item.status == ToolCallStatus.pending;
|
item.status == ToolCallStatus.pending;
|
||||||
},
|
},
|
||||||
'has pending tool call',
|
'has pending tool call',
|
||||||
@@ -220,7 +220,7 @@ void main() {
|
|||||||
ToolCallItem(
|
ToolCallItem(
|
||||||
id: 'tc_1',
|
id: 'tc_1',
|
||||||
callId: 'tc_1',
|
callId: 'tc_1',
|
||||||
toolName: 'navigate_to_route',
|
toolName: 'front.navigate_to_route',
|
||||||
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
|
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
|
||||||
status: ToolCallStatus.executing,
|
status: ToolCallStatus.executing,
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
@@ -241,5 +241,40 @@ void main() {
|
|||||||
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
|
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
blocTest<ChatBloc, ChatState>(
|
||||||
|
'toolCallResult with ui in payload.result adds ToolResultItem',
|
||||||
|
build: () => chatBloc,
|
||||||
|
seed: () => ChatState(
|
||||||
|
items: [
|
||||||
|
ToolCallItem(
|
||||||
|
id: 'tc_2',
|
||||||
|
callId: 'tc_2',
|
||||||
|
toolName: 'back.mutate_calendar_event',
|
||||||
|
args: {'operation': 'create'},
|
||||||
|
status: ToolCallStatus.executing,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
sender: MessageSender.ai,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
act: (bloc) {
|
||||||
|
service.onEvent(
|
||||||
|
ToolCallResultEvent(
|
||||||
|
messageId: 'msg_tool_2',
|
||||||
|
toolCallId: 'tc_2',
|
||||||
|
content:
|
||||||
|
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
expect: () => [
|
||||||
|
isA<ChatState>().having(
|
||||||
|
(s) => s.items.first is ToolResultItem,
|
||||||
|
'first item is ToolResultItem',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('getTool', () {
|
group('getTool', () {
|
||||||
test('returns tool definition for create_calendar_event', () {
|
test('returns tool definition for front.navigate_to_route', () {
|
||||||
final tool = ToolRegistry.getTool('create_calendar_event');
|
final tool = ToolRegistry.getTool('front.navigate_to_route');
|
||||||
|
|
||||||
expect(tool, isNotNull);
|
expect(tool, isNotNull);
|
||||||
expect(tool!.name, 'create_calendar_event');
|
expect(tool!.name, 'front.navigate_to_route');
|
||||||
expect(tool.description, isNotEmpty);
|
expect(tool.description, isNotEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,26 +26,16 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('validateArgs', () {
|
group('validateArgs', () {
|
||||||
test('returns error for empty args (missing title)', () {
|
test('returns error for empty args (missing target)', () {
|
||||||
final result = ToolRegistry.validateArgs('create_calendar_event', {});
|
final result = ToolRegistry.validateArgs('front.navigate_to_route', {});
|
||||||
|
|
||||||
expect(result.ok, false);
|
expect(result.ok, false);
|
||||||
expect(result.error, contains('title'));
|
expect(result.error, contains('target'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns error when missing startAt', () {
|
test('returns ok: true for valid args', () {
|
||||||
final result = ToolRegistry.validateArgs('create_calendar_event', {
|
final result = ToolRegistry.validateArgs('front.navigate_to_route', {
|
||||||
'title': 'Test Event',
|
'target': '/settings',
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.ok, false);
|
|
||||||
expect(result.error, contains('startAt'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns ok: true for valid args with title and startAt', () {
|
|
||||||
final result = ToolRegistry.validateArgs('create_calendar_event', {
|
|
||||||
'title': 'x',
|
|
||||||
'startAt': 'x',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok, true);
|
expect(result.ok, true);
|
||||||
@@ -61,17 +51,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('execute', () {
|
group('execute', () {
|
||||||
test('returns eventId on success', () async {
|
|
||||||
final result = await ToolRegistry.execute('create_calendar_event', {
|
|
||||||
'title': 'Test Meeting',
|
|
||||||
'startAt': '2026-03-01T10:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result['eventId'], isNotNull);
|
|
||||||
expect(result['ok'], true);
|
|
||||||
expect(result['title'], 'Test Meeting');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws ToolNotFoundException for unknown tool', () async {
|
test('throws ToolNotFoundException for unknown tool', () async {
|
||||||
expect(
|
expect(
|
||||||
() => ToolRegistry.execute('unknown_tool', {}),
|
() => ToolRegistry.execute('unknown_tool', {}),
|
||||||
@@ -79,22 +58,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('includes optional fields in result', () async {
|
test('front.navigate_to_route rejects disallowed target', () async {
|
||||||
final result = await ToolRegistry.execute('create_calendar_event', {
|
final result = await ToolRegistry.execute('front.navigate_to_route', {
|
||||||
'title': 'Test',
|
|
||||||
'startAt': '2026-03-01T10:00:00Z',
|
|
||||||
'description': 'Description',
|
|
||||||
'location': 'Room A',
|
|
||||||
'endAt': '2026-03-01T11:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result['description'], 'Description');
|
|
||||||
expect(result['location'], 'Room A');
|
|
||||||
expect(result['endAt'], '2026-03-01T11:00:00Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigate_to_route rejects disallowed target', () async {
|
|
||||||
final result = await ToolRegistry.execute('navigate_to_route', {
|
|
||||||
'target': '/admin',
|
'target': '/admin',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,23 +67,26 @@ void main() {
|
|||||||
expect(result['error'], contains('not allowed'));
|
expect(result['error'], contains('not allowed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigate_to_route executes allowed target when navigator is bound', () async {
|
test(
|
||||||
String? navigatedTo;
|
'front.navigate_to_route executes allowed target when navigator is bound',
|
||||||
bool replaced = false;
|
() async {
|
||||||
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
|
String? navigatedTo;
|
||||||
navigatedTo = target;
|
bool replaced = false;
|
||||||
replaced = replace;
|
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
|
||||||
});
|
navigatedTo = target;
|
||||||
|
replaced = replace;
|
||||||
|
});
|
||||||
|
|
||||||
final result = await ToolRegistry.execute('navigate_to_route', {
|
final result = await ToolRegistry.execute('front.navigate_to_route', {
|
||||||
'target': '/settings',
|
'target': '/settings',
|
||||||
'replace': true,
|
'replace': true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result['ok'], true);
|
expect(result['ok'], true);
|
||||||
expect(navigatedTo, '/settings');
|
expect(navigatedTo, '/settings');
|
||||||
expect(replaced, true);
|
expect(replaced, true);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('getAllTools', () {
|
group('getAllTools', () {
|
||||||
@@ -126,7 +94,8 @@ void main() {
|
|||||||
final tools = ToolRegistry.getAllTools();
|
final tools = ToolRegistry.getAllTools();
|
||||||
|
|
||||||
expect(tools, isNotEmpty);
|
expect(tools, isNotEmpty);
|
||||||
expect(tools.any((t) => t.name == 'create_calendar_event'), true);
|
expect(tools.any((t) => t.name == 'front.navigate_to_route'), true);
|
||||||
|
expect(tools.any((t) => t.name == 'create_calendar_event'), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,61 @@ void main() {
|
|||||||
expect(find.text('AI生成'), findsOneWidget);
|
expect(find.text('AI生成'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('calendar_card.v1 renders agent generated tag', (tester) async {
|
||||||
|
final card = UiCard(
|
||||||
|
cardType: 'calendar_card.v1',
|
||||||
|
data: CalendarCardData(
|
||||||
|
id: 'evt_001',
|
||||||
|
title: 'Meeting',
|
||||||
|
startAt: '2026-03-01T10:00:00Z',
|
||||||
|
sourceType: 'agent_generated',
|
||||||
|
).toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('AI生成'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calendar_event_list.v1 renders list items', (tester) async {
|
||||||
|
final card = UiCard(
|
||||||
|
cardType: 'calendar_event_list.v1',
|
||||||
|
data: {
|
||||||
|
'items': [
|
||||||
|
{'id': 'evt_1', 'title': '晨会'},
|
||||||
|
{'id': 'evt_2', 'title': '评审'},
|
||||||
|
],
|
||||||
|
'pagination': {'page': 1, 'total': 2},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('日程列表'), findsOneWidget);
|
||||||
|
expect(find.text('晨会'), findsOneWidget);
|
||||||
|
expect(find.text('评审'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calendar_operation.v1 renders operation message', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final card = UiCard(
|
||||||
|
cardType: 'calendar_operation.v1',
|
||||||
|
data: {'operation': 'delete', 'ok': true, 'message': '日程已删除'},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('日程delete结果'), findsOneWidget);
|
||||||
|
expect(find.text('日程已删除'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('error_card.v1 renders error message', (tester) async {
|
testWidgets('error_card.v1 renders error message', (tester) async {
|
||||||
final card = UiCard(
|
final card = UiCard(
|
||||||
cardType: 'error_card.v1',
|
cardType: 'error_card.v1',
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:social_app/core/api/api_exception.dart';
|
||||||
|
import 'package:social_app/features/home/data/voice_recorder.dart';
|
||||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||||
|
|
||||||
|
class _FakeVoiceRecorder implements VoiceRecorder {
|
||||||
|
bool started = false;
|
||||||
|
String? stoppedPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> start() async {
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> stop() async {
|
||||||
|
started = false;
|
||||||
|
stoppedPath = '/tmp/test-audio.wav';
|
||||||
|
return stoppedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {}
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('HomeScreen Widget Tests', () {
|
group('HomeScreen Widget Tests', () {
|
||||||
testWidgets('displays input field', (WidgetTester tester) async {
|
testWidgets('displays input field', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
@@ -14,7 +38,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('displays header icons', (WidgetTester tester) async {
|
testWidgets('displays header icons', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
|
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
|
||||||
@@ -25,10 +51,116 @@ void main() {
|
|||||||
testWidgets('displays send or mic icon based on input', (
|
testWidgets('displays send or mic icon based on input', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
|
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('tap mic starts recording and shows listening state', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final fakeRecorder = _FakeVoiceRecorder();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(fakeRecorder.started, true);
|
||||||
|
expect(find.text('正在聆听...'), findsOneWidget);
|
||||||
|
expect(find.byIcon(LucideIcons.square), findsOneWidget);
|
||||||
|
expect(find.byIcon(LucideIcons.send), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tap send while recording transcribes and auto sends message', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final fakeRecorder = _FakeVoiceRecorder();
|
||||||
|
String? sentTranscript;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: HomeScreen(
|
||||||
|
voiceRecorder: fakeRecorder,
|
||||||
|
autoLoadHistory: false,
|
||||||
|
onTranscribeAudio: (filePath) async {
|
||||||
|
expect(filePath, '/tmp/test-audio.wav');
|
||||||
|
return '语音自动发送';
|
||||||
|
},
|
||||||
|
onAutoSendTranscript: (transcript) async {
|
||||||
|
sentTranscript = transcript;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.send));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(sentTranscript, '语音自动发送');
|
||||||
|
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tap stop transcribes audio and fills input', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final fakeRecorder = _FakeVoiceRecorder();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: HomeScreen(
|
||||||
|
voiceRecorder: fakeRecorder,
|
||||||
|
autoLoadHistory: false,
|
||||||
|
onTranscribeAudio: (filePath) async {
|
||||||
|
expect(filePath, '/tmp/test-audio.wav');
|
||||||
|
return '语音转文字结果';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.square));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('语音转文字结果'), findsOneWidget);
|
||||||
|
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tap stop shows readable unauthorized message', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final fakeRecorder = _FakeVoiceRecorder();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: HomeScreen(
|
||||||
|
voiceRecorder: fakeRecorder,
|
||||||
|
autoLoadHistory: false,
|
||||||
|
onTranscribeAudio: (_) async {
|
||||||
|
throw const UnauthorizedException();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.mic));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.square));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('请重新登录'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,11 +141,17 @@ class CrewAIRuntime:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_backend_args(execution_data: dict[str, Any]) -> dict[str, object]:
|
def _sanitize_backend_args(execution_data: dict[str, Any]) -> dict[str, object]:
|
||||||
dropped = {"event_id", "id", "message", "status", "result"}
|
dropped = {"event_id", "id", "message", "result"}
|
||||||
cleaned: dict[str, object] = {}
|
cleaned: dict[str, object] = {}
|
||||||
for key, value in execution_data.items():
|
for key, value in execution_data.items():
|
||||||
if not isinstance(key, str) or key in dropped:
|
if not isinstance(key, str) or key in dropped:
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
key == "status"
|
||||||
|
and isinstance(value, str)
|
||||||
|
and value.upper() in {"SUCCESS", "PARTIAL", "FAILED"}
|
||||||
|
):
|
||||||
|
continue
|
||||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||||
cleaned[key] = value
|
cleaned[key] = value
|
||||||
return cleaned
|
return cleaned
|
||||||
@@ -170,7 +176,7 @@ class CrewAIRuntime:
|
|||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
backend_names = self._backend_tool_names(execution_tools)
|
backend_names = self._backend_tool_names(execution_tools)
|
||||||
if len(backend_names) != 1:
|
if not backend_names:
|
||||||
return None
|
return None
|
||||||
if not hasattr(execution_result, "status") or not hasattr(
|
if not hasattr(execution_result, "status") or not hasattr(
|
||||||
execution_result, "execution_data"
|
execution_result, "execution_data"
|
||||||
@@ -190,7 +196,39 @@ class CrewAIRuntime:
|
|||||||
args = self._sanitize_backend_args(raw_data)
|
args = self._sanitize_backend_args(raw_data)
|
||||||
if not args:
|
if not args:
|
||||||
return None
|
return None
|
||||||
tool_name = backend_names[0]
|
if len(backend_names) == 1:
|
||||||
|
tool_name = backend_names[0]
|
||||||
|
else:
|
||||||
|
mutate_name = "back.mutate_calendar_event"
|
||||||
|
list_name = "back.list_calendar_events"
|
||||||
|
write_keys = {
|
||||||
|
"operation",
|
||||||
|
"eventId",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"startAt",
|
||||||
|
"endAt",
|
||||||
|
"timezone",
|
||||||
|
"location",
|
||||||
|
"color",
|
||||||
|
"status",
|
||||||
|
}
|
||||||
|
list_keys = {"page", "pageSize"}
|
||||||
|
has_write_keys = any(key in args for key in write_keys)
|
||||||
|
has_event_id = "eventId" in args
|
||||||
|
if mutate_name in backend_names and has_write_keys:
|
||||||
|
tool_name = mutate_name
|
||||||
|
if "operation" not in args:
|
||||||
|
if has_event_id:
|
||||||
|
return None
|
||||||
|
args = {"operation": "create", **args}
|
||||||
|
elif list_name in backend_names and (
|
||||||
|
any(key in args for key in list_keys)
|
||||||
|
or not any(key in args for key in write_keys)
|
||||||
|
):
|
||||||
|
tool_name = list_name
|
||||||
|
else:
|
||||||
|
return None
|
||||||
result = self._backend_tool_handler(tool_name, args)
|
result = self._backend_tool_handler(tool_name, args)
|
||||||
synthesized_call = {
|
synthesized_call = {
|
||||||
"name": tool_name,
|
"name": tool_name,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
|
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
|
||||||
CREATE_CALENDAR_EVENT_TOOL,
|
LIST_CALENDAR_EVENTS_TOOL,
|
||||||
|
MUTATE_CALENDAR_EVENT_TOOL,
|
||||||
)
|
)
|
||||||
|
|
||||||
REGISTERED_TOOLS = {
|
REGISTERED_TOOLS = {
|
||||||
CREATE_CALENDAR_EVENT_TOOL.name: CREATE_CALENDAR_EVENT_TOOL,
|
LIST_CALENDAR_EVENTS_TOOL.name: LIST_CALENDAR_EVENTS_TOOL,
|
||||||
|
MUTATE_CALENDAR_EVENT_TOOL.name: MUTATE_CALENDAR_EVENT_TOOL,
|
||||||
}
|
}
|
||||||
|
|
||||||
__all__ = ["REGISTERED_TOOLS"]
|
__all__ = ["REGISTERED_TOOLS"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -8,10 +9,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from core.auth.models import CurrentUser
|
from core.auth.models import CurrentUser
|
||||||
from core.agent.infrastructure.crewai.tools.base import CrewAIToolSpec
|
from core.agent.infrastructure.crewai.tools.base import CrewAIToolSpec
|
||||||
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
|
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
|
||||||
from v1.schedule_items.schemas import ScheduleItemCreateRequest, ScheduleItemMetadata
|
from v1.schedule_items.schemas import (
|
||||||
|
ScheduleItemCreateRequest,
|
||||||
|
ScheduleItemMetadata,
|
||||||
|
ScheduleItemStatus,
|
||||||
|
ScheduleItemUpdateRequest,
|
||||||
|
)
|
||||||
from v1.schedule_items.service import ScheduleItemService
|
from v1.schedule_items.service import ScheduleItemService
|
||||||
|
|
||||||
|
|
||||||
|
_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime(value: object) -> datetime | None:
|
def _parse_datetime(value: object) -> datetime | None:
|
||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
return None
|
return None
|
||||||
@@ -24,10 +33,122 @@ def _parse_datetime(value: object) -> datetime | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _execute_create_calendar_event(
|
def _parse_positive_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
default: int,
|
||||||
|
minimum: int,
|
||||||
|
maximum: int,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
candidate: int | float | str
|
||||||
|
if isinstance(value, (int, float, str)):
|
||||||
|
candidate = value
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
if isinstance(candidate, str):
|
||||||
|
candidate = candidate.strip()
|
||||||
|
try:
|
||||||
|
parsed = int(candidate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
if parsed < minimum:
|
||||||
|
return minimum
|
||||||
|
if parsed > maximum:
|
||||||
|
return maximum
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_id(value: object) -> UUID:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError("eventId is required")
|
||||||
|
try:
|
||||||
|
return UUID(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("eventId must be a valid UUID") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _service(session: AsyncSession, owner_id: UUID) -> ScheduleItemService:
|
||||||
|
return ScheduleItemService(
|
||||||
|
repository=SQLAlchemyScheduleItemRepository(session),
|
||||||
|
session=session,
|
||||||
|
current_user=CurrentUser(id=owner_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_metadata(tool_args: dict[str, object]) -> ScheduleItemMetadata:
|
||||||
|
location = tool_args.get("location")
|
||||||
|
location_value = location.strip() if isinstance(location, str) else None
|
||||||
|
color = tool_args.get("color")
|
||||||
|
raw_color = color.strip() if isinstance(color, str) and color.strip() else "#4F46E5"
|
||||||
|
color_value = raw_color if _HEX_COLOR_PATTERN.match(raw_color) else "#4F46E5"
|
||||||
|
return ScheduleItemMetadata(location=location_value, color=color_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_payload(event: object) -> dict[str, object]:
|
||||||
|
event_id = str(getattr(event, "id"))
|
||||||
|
metadata = getattr(event, "metadata", None)
|
||||||
|
location_value = getattr(metadata, "location", None)
|
||||||
|
color_value = getattr(metadata, "color", None) or "#4F46E5"
|
||||||
|
return {
|
||||||
|
"id": event_id,
|
||||||
|
"title": getattr(event, "title"),
|
||||||
|
"description": getattr(event, "description"),
|
||||||
|
"startAt": getattr(event, "start_at").isoformat(),
|
||||||
|
"endAt": (
|
||||||
|
getattr(event, "end_at").isoformat()
|
||||||
|
if getattr(event, "end_at") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"timezone": getattr(event, "timezone"),
|
||||||
|
"location": location_value,
|
||||||
|
"color": color_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_list_calendar_events(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
owner_id: UUID,
|
owner_id: UUID,
|
||||||
tool_args: dict[str, object],
|
tool_args: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
page = _parse_positive_int(
|
||||||
|
tool_args.get("page"),
|
||||||
|
default=1,
|
||||||
|
minimum=1,
|
||||||
|
maximum=100000,
|
||||||
|
)
|
||||||
|
page_size = _parse_positive_int(
|
||||||
|
tool_args.get("pageSize"),
|
||||||
|
default=20,
|
||||||
|
minimum=1,
|
||||||
|
maximum=100,
|
||||||
|
)
|
||||||
|
service = _service(session, owner_id)
|
||||||
|
items, total = await service.list_paginated(page=page, page_size=page_size)
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size) if total else 0
|
||||||
|
return {
|
||||||
|
"type": "calendar_event_list.v1",
|
||||||
|
"version": "v1",
|
||||||
|
"data": {
|
||||||
|
"items": [_event_payload(item) for item in items],
|
||||||
|
"pagination": {
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"total": total,
|
||||||
|
"totalPages": total_pages,
|
||||||
|
},
|
||||||
|
"ok": True,
|
||||||
|
"message": "已获取日程列表",
|
||||||
|
},
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_create(
|
||||||
|
*,
|
||||||
|
service: ScheduleItemService,
|
||||||
|
tool_args: dict[str, object],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
title = str(tool_args.get("title", "新的日程")).strip() or "新的日程"
|
title = str(tool_args.get("title", "新的日程")).strip() or "新的日程"
|
||||||
description = str(tool_args.get("description", "")).strip() or None
|
description = str(tool_args.get("description", "")).strip() or None
|
||||||
@@ -35,15 +156,8 @@ async def _execute_create_calendar_event(
|
|||||||
if start_at is None:
|
if start_at is None:
|
||||||
start_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
start_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||||
end_at = _parse_datetime(tool_args.get("endAt"))
|
end_at = _parse_datetime(tool_args.get("endAt"))
|
||||||
timezone_value = str(tool_args.get("timezone", "Asia/Shanghai"))
|
timezone_value = (
|
||||||
location = tool_args.get("location")
|
str(tool_args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai"
|
||||||
location_value = str(location) if isinstance(location, str) else None
|
|
||||||
metadata = ScheduleItemMetadata(location=location_value, color="#4F46E5")
|
|
||||||
|
|
||||||
service = ScheduleItemService(
|
|
||||||
repository=SQLAlchemyScheduleItemRepository(session),
|
|
||||||
session=session,
|
|
||||||
current_user=CurrentUser(id=owner_id),
|
|
||||||
)
|
)
|
||||||
created = await service.create_agent_generated(
|
created = await service.create_agent_generated(
|
||||||
ScheduleItemCreateRequest(
|
ScheduleItemCreateRequest(
|
||||||
@@ -52,22 +166,16 @@ async def _execute_create_calendar_event(
|
|||||||
start_at=start_at,
|
start_at=start_at,
|
||||||
end_at=end_at,
|
end_at=end_at,
|
||||||
timezone=timezone_value,
|
timezone=timezone_value,
|
||||||
metadata=metadata,
|
metadata=_resolve_metadata(tool_args),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
event_id = str(created.id)
|
event_data = _event_payload(created)
|
||||||
|
event_id = str(event_data["id"])
|
||||||
return {
|
return {
|
||||||
"type": "calendar_card.v1",
|
"type": "calendar_card.v1",
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"data": {
|
"data": {
|
||||||
"id": event_id,
|
**event_data,
|
||||||
"title": created.title,
|
|
||||||
"description": created.description,
|
|
||||||
"startAt": created.start_at.isoformat(),
|
|
||||||
"endAt": created.end_at.isoformat() if created.end_at is not None else None,
|
|
||||||
"timezone": created.timezone,
|
|
||||||
"location": location_value,
|
|
||||||
"color": "#4F46E5",
|
|
||||||
"sourceType": "agent_generated",
|
"sourceType": "agent_generated",
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": "日程已创建",
|
"message": "日程已创建",
|
||||||
@@ -82,8 +190,125 @@ async def _execute_create_calendar_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CREATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec(
|
async def _execute_update(
|
||||||
name="back.create_calendar_event",
|
*,
|
||||||
|
service: ScheduleItemService,
|
||||||
|
tool_args: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
event_id = _parse_event_id(tool_args.get("eventId"))
|
||||||
|
update_data: dict[str, object] = {}
|
||||||
|
for source_key, target_key in (
|
||||||
|
("title", "title"),
|
||||||
|
("description", "description"),
|
||||||
|
("timezone", "timezone"),
|
||||||
|
):
|
||||||
|
value = tool_args.get(source_key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
update_data[target_key] = value.strip()
|
||||||
|
start_at = _parse_datetime(tool_args.get("startAt"))
|
||||||
|
if start_at is not None:
|
||||||
|
update_data["start_at"] = start_at
|
||||||
|
end_at = _parse_datetime(tool_args.get("endAt"))
|
||||||
|
if end_at is not None:
|
||||||
|
update_data["end_at"] = end_at
|
||||||
|
status_value = tool_args.get("status")
|
||||||
|
if isinstance(status_value, str) and status_value.strip():
|
||||||
|
try:
|
||||||
|
update_data["status"] = ScheduleItemStatus(status_value.strip().lower())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"status must be one of: active, completed, canceled, archived"
|
||||||
|
) from exc
|
||||||
|
has_location = isinstance(tool_args.get("location"), str)
|
||||||
|
has_color = isinstance(tool_args.get("color"), str)
|
||||||
|
if has_location or has_color:
|
||||||
|
existing = await service.get_by_id(event_id)
|
||||||
|
metadata_dump = (
|
||||||
|
existing.metadata.model_dump() if existing.metadata is not None else {}
|
||||||
|
)
|
||||||
|
if has_location:
|
||||||
|
metadata_dump["location"] = str(tool_args.get("location")).strip() or None
|
||||||
|
if has_color:
|
||||||
|
color = str(tool_args.get("color")).strip()
|
||||||
|
if not color:
|
||||||
|
metadata_dump["color"] = None
|
||||||
|
elif _HEX_COLOR_PATTERN.match(color):
|
||||||
|
metadata_dump["color"] = color
|
||||||
|
else:
|
||||||
|
raise ValueError("color must be a hex string like #RRGGBB")
|
||||||
|
update_data["metadata"] = ScheduleItemMetadata.model_validate(metadata_dump)
|
||||||
|
|
||||||
|
updated = await service.update(
|
||||||
|
event_id,
|
||||||
|
ScheduleItemUpdateRequest.model_validate(update_data),
|
||||||
|
)
|
||||||
|
event_data = _event_payload(updated)
|
||||||
|
return {
|
||||||
|
"type": "calendar_card.v1",
|
||||||
|
"version": "v1",
|
||||||
|
"data": {
|
||||||
|
**event_data,
|
||||||
|
"sourceType": "agent_generated",
|
||||||
|
"ok": True,
|
||||||
|
"message": "日程已更新",
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"label": "查看详情",
|
||||||
|
"target": f"/calendar/events/{event_data['id']}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_delete(
|
||||||
|
*,
|
||||||
|
service: ScheduleItemService,
|
||||||
|
tool_args: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
event_id = _parse_event_id(tool_args.get("eventId"))
|
||||||
|
await service.delete(event_id)
|
||||||
|
return {
|
||||||
|
"type": "calendar_operation.v1",
|
||||||
|
"version": "v1",
|
||||||
|
"data": {
|
||||||
|
"operation": "delete",
|
||||||
|
"id": str(event_id),
|
||||||
|
"ok": True,
|
||||||
|
"message": "日程已删除",
|
||||||
|
},
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_mutate_calendar_event(
|
||||||
|
session: AsyncSession,
|
||||||
|
owner_id: UUID,
|
||||||
|
tool_args: dict[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
operation_raw = tool_args.get("operation")
|
||||||
|
if not isinstance(operation_raw, str) or not operation_raw.strip():
|
||||||
|
raise ValueError("operation is required")
|
||||||
|
operation = operation_raw.strip().lower()
|
||||||
|
service = _service(session, owner_id)
|
||||||
|
if operation == "create":
|
||||||
|
return await _execute_create(service=service, tool_args=tool_args)
|
||||||
|
if operation == "update":
|
||||||
|
return await _execute_update(service=service, tool_args=tool_args)
|
||||||
|
if operation == "delete":
|
||||||
|
return await _execute_delete(service=service, tool_args=tool_args)
|
||||||
|
raise ValueError("operation must be one of: create, update, delete")
|
||||||
|
|
||||||
|
|
||||||
|
LIST_CALENDAR_EVENTS_TOOL = CrewAIToolSpec(
|
||||||
|
name="back.list_calendar_events",
|
||||||
target="backend",
|
target="backend",
|
||||||
executor=_execute_create_calendar_event,
|
executor=_execute_list_calendar_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
MUTATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec(
|
||||||
|
name="back.mutate_calendar_event",
|
||||||
|
target="backend",
|
||||||
|
executor=_execute_mutate_calendar_event,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ from core.agent.infrastructure.crewai.tools import REGISTERED_TOOLS
|
|||||||
|
|
||||||
STAGE_TOOL_ALLOWLIST: dict[str, list[str]] = {
|
STAGE_TOOL_ALLOWLIST: dict[str, list[str]] = {
|
||||||
"intent": [],
|
"intent": [],
|
||||||
"execution": ["back.create_calendar_event"],
|
"execution": [
|
||||||
|
"back.list_calendar_events",
|
||||||
|
"back.mutate_calendar_event",
|
||||||
|
],
|
||||||
"organization": [],
|
"organization": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import TYPE_CHECKING, Protocol
|
from typing import TYPE_CHECKING, Protocol
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from core.db.base_repository import BaseRepository
|
from core.db.base_repository import BaseRepository
|
||||||
@@ -33,6 +33,13 @@ class ScheduleItemRepository(Protocol):
|
|||||||
async def list_by_date_range(
|
async def list_by_date_range(
|
||||||
self, owner_id: UUID, start_at: datetime, end_at: datetime
|
self, owner_id: UUID, start_at: datetime, end_at: datetime
|
||||||
) -> list[ScheduleItem]: ...
|
) -> list[ScheduleItem]: ...
|
||||||
|
async def list_paginated(
|
||||||
|
self,
|
||||||
|
owner_id: UUID,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[list[ScheduleItem], int]: ...
|
||||||
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
|
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -131,11 +138,46 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
|
|||||||
logger.exception("Schedule item list failed", owner_id=str(owner_id))
|
logger.exception("Schedule item list failed", owner_id=str(owner_id))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def list_paginated(
|
||||||
|
self,
|
||||||
|
owner_id: UUID,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[list[ScheduleItem], int]:
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
try:
|
||||||
|
count_stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ScheduleItem)
|
||||||
|
.where(ScheduleItem.owner_id == owner_id)
|
||||||
|
.where(ScheduleItem.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
count_result = await self._session.execute(count_stmt)
|
||||||
|
total = int(count_result.scalar_one() or 0)
|
||||||
|
|
||||||
|
items_stmt = (
|
||||||
|
select(ScheduleItem)
|
||||||
|
.where(ScheduleItem.owner_id == owner_id)
|
||||||
|
.where(ScheduleItem.deleted_at.is_(None))
|
||||||
|
.order_by(ScheduleItem.start_at.asc(), ScheduleItem.id.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
items_result = await self._session.execute(items_stmt)
|
||||||
|
items = list(items_result.scalars().all())
|
||||||
|
return items, total
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.exception(
|
||||||
|
"Schedule item paginated list failed",
|
||||||
|
owner_id=str(owner_id),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def create_subscription(self, data: dict) -> ScheduleSubscription:
|
async def create_subscription(self, data: dict) -> ScheduleSubscription:
|
||||||
sub = ScheduleSubscription(**data)
|
sub = ScheduleSubscription(**data)
|
||||||
self._session.add(sub)
|
self._session.add(sub)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return sub
|
return sub
|
||||||
|
|
||||||
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None:
|
|
||||||
return await super().get_by_id(entity_id)
|
|
||||||
|
|||||||
@@ -202,6 +202,34 @@ class ScheduleItemService(BaseService):
|
|||||||
|
|
||||||
return [self._to_response(item) for item in items]
|
return [self._to_response(item) for item in items]
|
||||||
|
|
||||||
|
async def list_paginated(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[list[ScheduleItemResponse], int]:
|
||||||
|
user_id = self.require_user_id()
|
||||||
|
if page < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="page must be >= 1")
|
||||||
|
if page_size < 1 or page_size > 100:
|
||||||
|
raise HTTPException(status_code=400, detail="page_size must be 1..100")
|
||||||
|
try:
|
||||||
|
items, total = await self._repository.list_paginated(
|
||||||
|
user_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to list schedule items with pagination",
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Schedule item store unavailable"
|
||||||
|
)
|
||||||
|
return [self._to_response(item) for item in items], total
|
||||||
|
|
||||||
async def share(
|
async def share(
|
||||||
self, item_id: UUID, request: ScheduleItemShareRequest
|
self, item_id: UUID, request: ScheduleItemShareRequest
|
||||||
) -> ScheduleItemShareResponse:
|
) -> ScheduleItemShareResponse:
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ async def test_agent_live_image_calendar_tool_persistence() -> None:
|
|||||||
else:
|
else:
|
||||||
payload = json.loads(str(downloaded))
|
payload = json.loads(str(downloaded))
|
||||||
|
|
||||||
assert payload["toolName"] == "back.create_calendar_event"
|
assert payload["toolName"] == "back.mutate_calendar_event"
|
||||||
finally:
|
finally:
|
||||||
if uploaded_paths:
|
if uploaded_paths:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import cast
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool import (
|
|
||||||
_execute_create_calendar_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_calendar_event_tool_returns_ui_schema_v1_top_level(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
event_id = uuid4()
|
|
||||||
created = SimpleNamespace(
|
|
||||||
id=event_id,
|
|
||||||
title="晨会",
|
|
||||||
description="同步计划",
|
|
||||||
start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc),
|
|
||||||
end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc),
|
|
||||||
timezone="Asia/Shanghai",
|
|
||||||
)
|
|
||||||
|
|
||||||
class _FakeService:
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
del kwargs
|
|
||||||
|
|
||||||
async def create_agent_generated(self, payload):
|
|
||||||
del payload
|
|
||||||
return created
|
|
||||||
|
|
||||||
class _FakeRepository:
|
|
||||||
def __init__(self, session) -> None:
|
|
||||||
del session
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool.ScheduleItemService",
|
|
||||||
_FakeService,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
|
|
||||||
_FakeRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = cast(
|
|
||||||
dict[str, object],
|
|
||||||
await _execute_create_calendar_event(
|
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
|
||||||
owner_id=uuid4(),
|
|
||||||
tool_args={"title": "晨会"},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["type"] == "calendar_card.v1"
|
|
||||||
assert result["version"] == "v1"
|
|
||||||
data = cast(dict[str, object], result["data"])
|
|
||||||
actions = cast(list[dict[str, object]], result["actions"])
|
|
||||||
assert data["id"] == str(event_id)
|
|
||||||
assert actions
|
|
||||||
@@ -119,7 +119,8 @@ def test_runtime_needs_execution_and_collects_front_tool_call() -> None:
|
|||||||
assert isinstance(tools, list)
|
assert isinstance(tools, list)
|
||||||
assert any(t.get("name") == "front.navigate_to_route" for t in tools)
|
assert any(t.get("name") == "front.navigate_to_route" for t in tools)
|
||||||
execution_tools = cast(list[dict[str, object]], calls[1]["tools"])
|
execution_tools = cast(list[dict[str, object]], calls[1]["tools"])
|
||||||
assert any(t.get("name") == "back.create_calendar_event" for t in execution_tools)
|
assert any(t.get("name") == "back.list_calendar_events" for t in execution_tools)
|
||||||
|
assert any(t.get("name") == "back.mutate_calendar_event" for t in execution_tools)
|
||||||
assert result["assistant_text"] == "do it"
|
assert result["assistant_text"] == "do it"
|
||||||
assert result["pending_front_tool"] == {
|
assert result["pending_front_tool"] == {
|
||||||
"name": "front.navigate_to_route",
|
"name": "front.navigate_to_route",
|
||||||
@@ -191,7 +192,7 @@ def test_runtime_multimodal_intent_receives_execution_tool_awareness() -> None:
|
|||||||
calls.append({"stage": stage, "tools": tools})
|
calls.append({"stage": stage, "tools": tools})
|
||||||
if stage == "intent":
|
if stage == "intent":
|
||||||
return (
|
return (
|
||||||
'{"route":"NEEDS_EXECUTION","intent_summary":"need tool","execution_brief":"call back.create_calendar_event","safety_flags":[]}',
|
'{"route":"NEEDS_EXECUTION","intent_summary":"need tool","execution_brief":"call back.mutate_calendar_event","safety_flags":[]}',
|
||||||
UsageCost(1, 1, 2, 0.01),
|
UsageCost(1, 1, 2, 0.01),
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
@@ -218,7 +219,8 @@ def test_runtime_multimodal_intent_receives_execution_tool_awareness() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
intent_tools = cast(list[dict[str, object]], calls[0]["tools"])
|
intent_tools = cast(list[dict[str, object]], calls[0]["tools"])
|
||||||
assert any(t.get("name") == "back.create_calendar_event" for t in intent_tools)
|
assert any(t.get("name") == "back.list_calendar_events" for t in intent_tools)
|
||||||
|
assert any(t.get("name") == "back.mutate_calendar_event" for t in intent_tools)
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() -> None:
|
def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() -> None:
|
||||||
@@ -267,18 +269,78 @@ def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() ->
|
|||||||
|
|
||||||
assert backend_calls == [
|
assert backend_calls == [
|
||||||
(
|
(
|
||||||
"back.create_calendar_event",
|
"back.mutate_calendar_event",
|
||||||
{"title": "项目评审", "timezone": "Asia/Shanghai"},
|
{
|
||||||
|
"operation": "create",
|
||||||
|
"title": "项目评审",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
tool_calls = cast(list[dict[str, object]], result["tool_calls"])
|
tool_calls = cast(list[dict[str, object]], result["tool_calls"])
|
||||||
assert any(
|
assert any(
|
||||||
call.get("target") == "backend"
|
call.get("target") == "backend"
|
||||||
and call.get("name") == "back.create_calendar_event"
|
and call.get("name") == "back.mutate_calendar_event"
|
||||||
for call in tool_calls
|
for call in tool_calls
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_does_not_synthesize_mutate_create_when_event_id_without_operation() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
|
runtime = _build_runtime()
|
||||||
|
backend_calls: list[tuple[str, dict[str, object]]] = []
|
||||||
|
|
||||||
|
def _backend_handler(
|
||||||
|
tool_name: str, tool_args: dict[str, object]
|
||||||
|
) -> dict[str, object]:
|
||||||
|
backend_calls.append((tool_name, tool_args))
|
||||||
|
return {"type": "ok", "version": "v1", "data": {}, "actions": []}
|
||||||
|
|
||||||
|
runtime.set_backend_tool_handler(_backend_handler)
|
||||||
|
|
||||||
|
def _fake_run_stage(self, **kwargs):
|
||||||
|
stage = kwargs["stage"]
|
||||||
|
if stage == "intent":
|
||||||
|
return (
|
||||||
|
'{"route":"NEEDS_EXECUTION","intent_summary":"update event","execution_brief":"update via backend tool","safety_flags":[]}',
|
||||||
|
UsageCost(1, 1, 2, 0.01),
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if stage == "execution":
|
||||||
|
return (
|
||||||
|
'{"status":"SUCCESS","execution_summary":"updated","execution_data":{"eventId":"1c7e85f6-a2b4-4da3-a143-7f9af8ea1a3d","title":"修正标题"},"report_brief":"done"}',
|
||||||
|
UsageCost(2, 2, 4, 0.02),
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'{"assistant_text":"ok","response_metadata":{}}',
|
||||||
|
UsageCost(1, 1, 2, 0.01),
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime._run_stage_with_crewai = MethodType(_fake_run_stage, runtime) # type: ignore[method-assign]
|
||||||
|
runtime.execute(user_input="更新日程", tools=[])
|
||||||
|
|
||||||
|
assert backend_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_sanitize_backend_args_keeps_business_status() -> None:
|
||||||
|
payload = {
|
||||||
|
"status": "completed",
|
||||||
|
"title": "日程",
|
||||||
|
"result": "ignore",
|
||||||
|
"id": "ignore",
|
||||||
|
}
|
||||||
|
assert CrewAIRuntime._sanitize_backend_args(payload) == {
|
||||||
|
"status": "completed",
|
||||||
|
"title": "日程",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_extracts_pending_front_tool_from_approval_required_shape() -> None:
|
def test_runtime_extracts_pending_front_tool_from_approval_required_shape() -> None:
|
||||||
runtime = _build_runtime()
|
runtime = _build_runtime()
|
||||||
|
|
||||||
@@ -423,7 +485,8 @@ def test_run_stage_with_crewai_uses_output_pydantic_for_stage(
|
|||||||
|
|
||||||
def test_runtime_backend_registry_check() -> None:
|
def test_runtime_backend_registry_check() -> None:
|
||||||
runtime = _build_runtime()
|
runtime = _build_runtime()
|
||||||
assert runtime.is_registered_backend_tool("back.create_calendar_event") is True
|
assert runtime.is_registered_backend_tool("back.list_calendar_events") is True
|
||||||
|
assert runtime.is_registered_backend_tool("back.mutate_calendar_event") is True
|
||||||
assert runtime.is_registered_backend_tool("back.unknown") is False
|
assert runtime.is_registered_backend_tool("back.unknown") is False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
|
||||||
|
_execute_list_calendar_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_calendar_events_tool_returns_paginated_payload_v1(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
first_id = uuid4()
|
||||||
|
second_id = uuid4()
|
||||||
|
items = [
|
||||||
|
SimpleNamespace(
|
||||||
|
id=first_id,
|
||||||
|
title="晨会",
|
||||||
|
description="同步",
|
||||||
|
start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
metadata=SimpleNamespace(location="会议室A", color="#4F46E5"),
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id=second_id,
|
||||||
|
title="评审",
|
||||||
|
description=None,
|
||||||
|
start_at=datetime(2026, 3, 8, 3, 0, tzinfo=timezone.utc),
|
||||||
|
end_at=None,
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
metadata=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _FakeService:
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
del kwargs
|
||||||
|
|
||||||
|
async def list_paginated(self, *, page: int, page_size: int):
|
||||||
|
assert page == 2
|
||||||
|
assert page_size == 10
|
||||||
|
return items, 37
|
||||||
|
|
||||||
|
class _FakeRepository:
|
||||||
|
def __init__(self, session) -> None:
|
||||||
|
del session
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
|
||||||
|
_FakeService,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
|
||||||
|
_FakeRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cast(
|
||||||
|
dict[str, object],
|
||||||
|
await _execute_list_calendar_events(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={"page": 2, "pageSize": 10},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "calendar_event_list.v1"
|
||||||
|
assert result["version"] == "v1"
|
||||||
|
data = cast(dict[str, object], result["data"])
|
||||||
|
pagination = cast(dict[str, object], data["pagination"])
|
||||||
|
events = cast(list[dict[str, object]], data["items"])
|
||||||
|
assert pagination == {
|
||||||
|
"page": 2,
|
||||||
|
"pageSize": 10,
|
||||||
|
"total": 37,
|
||||||
|
"totalPages": 4,
|
||||||
|
}
|
||||||
|
assert events[0]["id"] == str(first_id)
|
||||||
|
assert events[0]["title"] == "晨会"
|
||||||
|
assert events[1]["id"] == str(second_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_calendar_events_tool_uses_default_pagination_when_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
class _FakeService:
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
del kwargs
|
||||||
|
|
||||||
|
async def list_paginated(self, *, page: int, page_size: int):
|
||||||
|
assert page == 1
|
||||||
|
assert page_size == 20
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
class _FakeRepository:
|
||||||
|
def __init__(self, session) -> None:
|
||||||
|
del session
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
|
||||||
|
_FakeService,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
|
||||||
|
_FakeRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cast(
|
||||||
|
dict[str, object],
|
||||||
|
await _execute_list_calendar_events(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
data = cast(dict[str, object], result["data"])
|
||||||
|
pagination = cast(dict[str, object], data["pagination"])
|
||||||
|
assert pagination["page"] == 1
|
||||||
|
assert pagination["pageSize"] == 20
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
|
||||||
|
_execute_mutate_calendar_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_calendar_event_create_returns_calendar_card_v1(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
created_id = uuid4()
|
||||||
|
|
||||||
|
class _FakeService:
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
del kwargs
|
||||||
|
|
||||||
|
async def create_agent_generated(self, payload):
|
||||||
|
assert payload.title == "晨会"
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=created_id,
|
||||||
|
title="晨会",
|
||||||
|
description="同步计划",
|
||||||
|
start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
metadata=SimpleNamespace(location="会议室A", color="#4F46E5"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class _FakeRepository:
|
||||||
|
def __init__(self, session) -> None:
|
||||||
|
del session
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
|
||||||
|
_FakeService,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
|
||||||
|
_FakeRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cast(
|
||||||
|
dict[str, object],
|
||||||
|
await _execute_mutate_calendar_event(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={
|
||||||
|
"operation": "create",
|
||||||
|
"title": "晨会",
|
||||||
|
"description": "同步计划",
|
||||||
|
"startAt": "2026-03-08T09:00:00+08:00",
|
||||||
|
"endAt": "2026-03-08T10:00:00+08:00",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"location": "会议室A",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "calendar_card.v1"
|
||||||
|
data = cast(dict[str, object], result["data"])
|
||||||
|
assert data["id"] == str(created_id)
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_calendar_event_update_requires_event_id() -> None:
|
||||||
|
with pytest.raises(ValueError, match="eventId is required"):
|
||||||
|
await _execute_mutate_calendar_event(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={"operation": "update", "title": "新标题"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_calendar_event_delete_returns_ack(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
deleted_id = uuid4()
|
||||||
|
|
||||||
|
class _FakeService:
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
del kwargs
|
||||||
|
|
||||||
|
async def delete(self, item_id):
|
||||||
|
assert item_id == deleted_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeRepository:
|
||||||
|
def __init__(self, session) -> None:
|
||||||
|
del session
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
|
||||||
|
_FakeService,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
|
||||||
|
_FakeRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cast(
|
||||||
|
dict[str, object],
|
||||||
|
await _execute_mutate_calendar_event(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={"operation": "delete", "eventId": str(deleted_id)},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "calendar_operation.v1"
|
||||||
|
data = cast(dict[str, object], result["data"])
|
||||||
|
assert data["operation"] == "delete"
|
||||||
|
assert data["id"] == str(deleted_id)
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_calendar_event_rejects_invalid_operation() -> None:
|
||||||
|
with pytest.raises(ValueError, match="operation"):
|
||||||
|
await _execute_mutate_calendar_event(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={"operation": "upsert"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mutate_calendar_event_update_rejects_invalid_color(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
event_id = uuid4()
|
||||||
|
|
||||||
|
class _FakeService:
|
||||||
|
def __init__(self, **kwargs) -> None:
|
||||||
|
del kwargs
|
||||||
|
|
||||||
|
async def get_by_id(self, item_id):
|
||||||
|
assert item_id == event_id
|
||||||
|
return SimpleNamespace(metadata=None)
|
||||||
|
|
||||||
|
class _FakeRepository:
|
||||||
|
def __init__(self, session) -> None:
|
||||||
|
del session
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
|
||||||
|
_FakeService,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
|
||||||
|
_FakeRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="color"):
|
||||||
|
await _execute_mutate_calendar_event(
|
||||||
|
session=cast(AsyncSession, SimpleNamespace()),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
tool_args={
|
||||||
|
"operation": "update",
|
||||||
|
"eventId": str(event_id),
|
||||||
|
"color": "blue",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -646,7 +646,7 @@ async def test_run_service_passes_user_context_system_prompt_to_runtime(
|
|||||||
tool_args,
|
tool_args,
|
||||||
):
|
):
|
||||||
del session, owner_id
|
del session, owner_id
|
||||||
assert tool_name == "back.create_calendar_event"
|
assert tool_name == "back.mutate_calendar_event"
|
||||||
assert "title" in tool_args
|
assert "title" in tool_args
|
||||||
return {
|
return {
|
||||||
"result": {"eventId": "evt-1", "ok": True},
|
"result": {"eventId": "evt-1", "ok": True},
|
||||||
@@ -788,7 +788,7 @@ async def test_run_service_emits_frontend_tool_pending_events(
|
|||||||
|
|
||||||
class _FakeRuntime:
|
class _FakeRuntime:
|
||||||
def is_registered_backend_tool(self, tool_name: str) -> bool:
|
def is_registered_backend_tool(self, tool_name: str) -> bool:
|
||||||
return tool_name == "back.create_calendar_event"
|
return tool_name == "back.mutate_calendar_event"
|
||||||
|
|
||||||
async def execute_backend_tool(
|
async def execute_backend_tool(
|
||||||
self,
|
self,
|
||||||
@@ -799,7 +799,7 @@ async def test_run_service_emits_frontend_tool_pending_events(
|
|||||||
tool_args,
|
tool_args,
|
||||||
):
|
):
|
||||||
del session, owner_id
|
del session, owner_id
|
||||||
assert tool_name == "back.create_calendar_event"
|
assert tool_name == "back.mutate_calendar_event"
|
||||||
assert "title" in tool_args
|
assert "title" in tool_args
|
||||||
return {
|
return {
|
||||||
"result": {"eventId": "evt-1", "ok": True},
|
"result": {"eventId": "evt-1", "ok": True},
|
||||||
@@ -957,7 +957,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
|
|||||||
|
|
||||||
class _FakeRuntime:
|
class _FakeRuntime:
|
||||||
def is_registered_backend_tool(self, tool_name: str) -> bool:
|
def is_registered_backend_tool(self, tool_name: str) -> bool:
|
||||||
return tool_name == "back.create_calendar_event"
|
return tool_name == "back.mutate_calendar_event"
|
||||||
|
|
||||||
async def execute_backend_tool(
|
async def execute_backend_tool(
|
||||||
self,
|
self,
|
||||||
@@ -968,7 +968,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
|
|||||||
tool_args,
|
tool_args,
|
||||||
):
|
):
|
||||||
del session, owner_id
|
del session, owner_id
|
||||||
assert tool_name == "back.create_calendar_event"
|
assert tool_name == "back.mutate_calendar_event"
|
||||||
assert "title" in tool_args
|
assert "title" in tool_args
|
||||||
return {
|
return {
|
||||||
"result": {"eventId": "evt-1", "ok": True},
|
"result": {"eventId": "evt-1", "ok": True},
|
||||||
@@ -1043,7 +1043,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
|
|||||||
text="请安排一个明早会议",
|
text="请安排一个明早会议",
|
||||||
tools=[
|
tools=[
|
||||||
{
|
{
|
||||||
"name": "back.create_calendar_event",
|
"name": "back.mutate_calendar_event",
|
||||||
"description": "create calendar",
|
"description": "create calendar",
|
||||||
"parameters": {"type": "object"},
|
"parameters": {"type": "object"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ def test_load_crewai_stage_tools_returns_expected_defaults() -> None:
|
|||||||
|
|
||||||
assert result == {
|
assert result == {
|
||||||
"intent": [],
|
"intent": [],
|
||||||
"execution": ["back.create_calendar_event"],
|
"execution": [
|
||||||
|
"back.list_calendar_events",
|
||||||
|
"back.mutate_calendar_event",
|
||||||
|
],
|
||||||
"organization": [],
|
"organization": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user