feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验
This commit is contained in:
@@ -114,7 +114,6 @@
|
|||||||
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */,
|
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */,
|
E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Pods;
|
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -471,13 +470,14 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = NHCUQ772U3;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
|
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -653,13 +653,14 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = NHCUQ772U3;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
|
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -675,13 +676,14 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = NHCUQ772U3;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
|
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -24,6 +26,38 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>需要使用相机来拍摄照片</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>需要访问局域网以连接开发服务器</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>需要访问相册来选择照片</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>192.168.66.57</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSTemporaryExceptionMinimumTLSVersion</key>
|
||||||
|
<string>TLSv1.0</string>
|
||||||
|
</dict>
|
||||||
|
<key>localhost</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSTemporaryExceptionMinimumTLSVersion</key>
|
||||||
|
<string>TLSv1.0</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -41,13 +75,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>需要使用相机来拍摄照片</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>需要访问相册来选择照片</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ abstract class ApiException implements Exception {
|
|||||||
(data['detail'] ?? data['message'] ?? data['error'])?.toString() ??
|
(data['detail'] ?? data['message'] ?? data['error'])?.toString() ??
|
||||||
'请求失败';
|
'请求失败';
|
||||||
} else {
|
} else {
|
||||||
detail = '请求失败';
|
detail = _networkErrorMessage(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
final localized = _localizeError(detail, statusCode);
|
final localized = _localizeError(detail, statusCode);
|
||||||
@@ -57,6 +57,21 @@ abstract class ApiException implements Exception {
|
|||||||
}
|
}
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _networkErrorMessage(DioException error) {
|
||||||
|
if (error.type == DioExceptionType.connectionTimeout ||
|
||||||
|
error.type == DioExceptionType.sendTimeout ||
|
||||||
|
error.type == DioExceptionType.receiveTimeout) {
|
||||||
|
return '网络超时,请确认手机与服务端在同一网络后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.type == DioExceptionType.connectionError ||
|
||||||
|
error.type == DioExceptionType.unknown) {
|
||||||
|
return '无法连接服务器。请在 iPhone 设置中为本应用开启“无线数据(WLAN与蜂窝网络)”,并确认本地网络权限已开启。';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '请求失败';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ServerException extends ApiException {
|
class ServerException extends ApiException {
|
||||||
|
|||||||
@@ -94,18 +94,18 @@ class UiSchemaRenderer {
|
|||||||
final status = _asString(node['status']);
|
final status = _asString(node['status']);
|
||||||
final style = switch (role) {
|
final style = switch (role) {
|
||||||
'title' => const TextStyle(
|
'title' => const TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.slate900,
|
color: AppColors.slate900,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
'subtitle' => const TextStyle(
|
'subtitle' => const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.slate800,
|
color: AppColors.slate800,
|
||||||
),
|
),
|
||||||
'caption' => const TextStyle(
|
'caption' => const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: AppColors.slate500,
|
color: AppColors.slate500,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
@@ -115,9 +115,9 @@ class UiSchemaRenderer {
|
|||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
_ => const TextStyle(
|
_ => const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 13,
|
||||||
color: AppColors.slate700,
|
color: AppColors.slate700,
|
||||||
height: 1.45,
|
height: 1.35,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
return Text(
|
return Text(
|
||||||
@@ -131,7 +131,7 @@ class UiSchemaRenderer {
|
|||||||
static Widget _renderIcon(Map<String, dynamic> node) {
|
static Widget _renderIcon(Map<String, dynamic> node) {
|
||||||
final value = _asString(node['value']);
|
final value = _asString(node['value']);
|
||||||
if (_asString(node['source']) == 'emoji' && value.isNotEmpty) {
|
if (_asString(node['source']) == 'emoji' && value.isNotEmpty) {
|
||||||
return Text(value, style: const TextStyle(fontSize: 20));
|
return Text(value, style: const TextStyle(fontSize: 18));
|
||||||
}
|
}
|
||||||
return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null));
|
return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null));
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ class UiSchemaRenderer {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.lg,
|
horizontal: AppSpacing.lg,
|
||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.sm,
|
||||||
),
|
),
|
||||||
backgroundColor: style == 'primary'
|
backgroundColor: style == 'primary'
|
||||||
? AppColors.authPrimaryButton
|
? AppColors.authPrimaryButton
|
||||||
@@ -196,7 +196,7 @@ class UiSchemaRenderer {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_asString(node['label'], fallback: '操作'),
|
_asString(node['label'], fallback: '操作'),
|
||||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -223,7 +223,7 @@ class UiSchemaRenderer {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.md,
|
horizontal: AppSpacing.md,
|
||||||
vertical: AppSpacing.sm,
|
vertical: AppSpacing.xs,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surfaceSecondary,
|
color: AppColors.surfaceSecondary,
|
||||||
@@ -237,7 +237,7 @@ class UiSchemaRenderer {
|
|||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: AppColors.slate500,
|
color: AppColors.slate500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -248,7 +248,7 @@ class UiSchemaRenderer {
|
|||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
color: AppColors.slate800,
|
color: AppColors.slate800,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -290,16 +290,16 @@ class UiSchemaRenderer {
|
|||||||
};
|
};
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
border: Border.all(color: borderColor),
|
border: Border.all(color: borderColor),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.slate200.withValues(alpha: 0.6),
|
color: AppColors.slate200.withValues(alpha: 0.35),
|
||||||
blurRadius: 20,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 10),
|
offset: const Offset(0, 6),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ const _transcribingStrokeWidth = 2.0;
|
|||||||
const _attachmentPreviewSize = 88.0;
|
const _attachmentPreviewSize = 88.0;
|
||||||
const _attachmentPreviewRadius = 10.0;
|
const _attachmentPreviewRadius = 10.0;
|
||||||
const _attachmentPreviewGap = 8.0;
|
const _attachmentPreviewGap = 8.0;
|
||||||
const _bottomStackReservedHeight = 140.0;
|
const _bottomStackReservedHeight = 116.0;
|
||||||
|
const _toolResultWidthFactor = 0.9;
|
||||||
|
const _pullRefreshMinVisibleMs = 450;
|
||||||
|
|
||||||
const homeConversationStageKey = ValueKey('home_conversation_stage');
|
const homeConversationStageKey = ValueKey('home_conversation_stage');
|
||||||
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
|
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
|
||||||
@@ -92,7 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
late final Future<void> Function(String transcript) _autoSendTranscript;
|
late final Future<void> Function(String transcript) _autoSendTranscript;
|
||||||
late final AnimationController _listeningAnimationController;
|
late final AnimationController _listeningAnimationController;
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isHoldToSpeakMode = false;
|
bool _isHoldToSpeakMode = true;
|
||||||
bool _isTranscribing = false;
|
bool _isTranscribing = false;
|
||||||
bool _isCancelGestureActive = false;
|
bool _isCancelGestureActive = false;
|
||||||
bool _isSendingMessage = false;
|
bool _isSendingMessage = false;
|
||||||
@@ -356,12 +358,29 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
if (_isPullRefreshing) {
|
if (_isPullRefreshing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final chatBloc = context.read<ChatBloc>();
|
||||||
|
if (chatBloc.state.isLoadingHistory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final hasEarlierHistory = chatBloc.state.hasEarlierHistory;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isPullRefreshing = true);
|
setState(() => _isPullRefreshing = true);
|
||||||
}
|
}
|
||||||
|
final startedAt = DateTime.now();
|
||||||
try {
|
try {
|
||||||
await context.read<ChatBloc>().loadMoreHistory();
|
if (hasEarlierHistory) {
|
||||||
|
await chatBloc.loadMoreHistory();
|
||||||
|
} else {
|
||||||
|
Toast.show(context, '没有更早的历史记录了', type: ToastType.info);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
final elapsed = DateTime.now().difference(startedAt);
|
||||||
|
final minDuration = const Duration(
|
||||||
|
milliseconds: _pullRefreshMinVisibleMs,
|
||||||
|
);
|
||||||
|
if (elapsed < minDuration) {
|
||||||
|
await Future.delayed(minDuration - elapsed);
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isPullRefreshing = false);
|
setState(() => _isPullRefreshing = false);
|
||||||
}
|
}
|
||||||
@@ -585,7 +604,32 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildToolResultItem(ToolResultItem item) {
|
Widget _buildToolResultItem(ToolResultItem item) {
|
||||||
return UiSchemaRenderer.renderSchema(item.uiSchema);
|
final rootNode = item.uiSchema['root'];
|
||||||
|
final appearance = rootNode is Map<String, dynamic>
|
||||||
|
? rootNode['appearance'] as String?
|
||||||
|
: null;
|
||||||
|
final needsOuterCard = appearance == null || appearance == 'plain';
|
||||||
|
final schemaContent = UiSchemaRenderer.renderSchema(item.uiSchema);
|
||||||
|
final wrappedContent = needsOuterCard
|
||||||
|
? Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
border: Border.all(color: AppColors.homeConversationBorder),
|
||||||
|
),
|
||||||
|
child: schemaContent,
|
||||||
|
)
|
||||||
|
: schemaContent;
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: _toolResultWidthFactor,
|
||||||
|
child: wrappedContent,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
|
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
|
||||||
@@ -733,8 +777,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onHoldToSpeakStart() {
|
void _onHoldToSpeakStart() {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.selectionClick();
|
||||||
HapticFeedback.vibrate();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isCancelGestureActive = false;
|
_isCancelGestureActive = false;
|
||||||
});
|
});
|
||||||
@@ -747,7 +790,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_cancelRecording(showToast: false);
|
_cancelRecording(showToast: false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.selectionClick();
|
||||||
_stopRecording(autoSendAfterTranscribe: true);
|
_stopRecording(autoSendAfterTranscribe: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,27 +59,29 @@ class _HomeBottomGlow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Align(
|
return IgnorePointer(
|
||||||
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: IgnorePointer(
|
child: Transform.translate(
|
||||||
|
offset: const Offset(0, AppSpacing.lg),
|
||||||
child: Container(
|
child: Container(
|
||||||
key: homeBottomGlowKey,
|
key: homeBottomGlowKey,
|
||||||
width: double.infinity,
|
width: AppSpacing.xxl * 12,
|
||||||
height: AppSpacing.xxl * 6,
|
height: AppSpacing.xxl * 3,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.2),
|
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.18),
|
||||||
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
|
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.12),
|
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.1),
|
||||||
blurRadius: AppSpacing.xxl * 2,
|
blurRadius: AppSpacing.xxl,
|
||||||
spreadRadius: AppSpacing.md,
|
spreadRadius: AppSpacing.sm,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ class HomeFloatingHeader extends StatelessWidget {
|
|||||||
key: homeFloatingHeaderKey,
|
key: homeFloatingHeaderKey,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
AppSpacing.sm,
|
AppSpacing.xs,
|
||||||
AppSpacing.lg,
|
AppSpacing.lg,
|
||||||
AppSpacing.sm,
|
AppSpacing.xs,
|
||||||
),
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.homeToolbarSurface,
|
color: AppColors.homeToolbarSurface,
|
||||||
@@ -93,6 +93,11 @@ class _HeaderIconButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: AppSpacing.xxl + AppSpacing.lg,
|
||||||
|
minHeight: AppSpacing.xxl + AppSpacing.lg,
|
||||||
|
),
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
icon: Icon(icon, size: AppSpacing.xxl, color: AppColors.slate900),
|
icon: Icon(icon, size: AppSpacing.xxl, color: AppColors.slate900),
|
||||||
);
|
);
|
||||||
@@ -109,6 +114,11 @@ class _MessagesButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: AppSpacing.xxl + AppSpacing.lg,
|
||||||
|
minHeight: AppSpacing.xxl + AppSpacing.lg,
|
||||||
|
),
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
icon: Stack(
|
icon: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
|
|||||||
@@ -59,6 +59,64 @@ void main() {
|
|||||||
expect(find.text('评审会'), findsOneWidget);
|
expect(find.text('评审会'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('renders batch result list items in one card', (tester) async {
|
||||||
|
final schema = {
|
||||||
|
'version': '2.0',
|
||||||
|
'root': {
|
||||||
|
'type': 'stack',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'appearance': 'card',
|
||||||
|
'status': 'warning',
|
||||||
|
'children': [
|
||||||
|
{'type': 'text', 'role': 'title', 'content': '日历操作完成'},
|
||||||
|
{
|
||||||
|
'type': 'stack',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'gap': 8,
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'type': 'stack',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'appearance': 'card',
|
||||||
|
'children': [
|
||||||
|
{'type': 'text', 'role': 'body', 'content': '#1 create'},
|
||||||
|
{'type': 'text', 'role': 'caption', 'content': '成功'},
|
||||||
|
{'type': 'text', 'role': 'caption', 'content': '日程「晨会」已创建'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'stack',
|
||||||
|
'direction': 'vertical',
|
||||||
|
'appearance': 'card',
|
||||||
|
'children': [
|
||||||
|
{'type': 'text', 'role': 'body', 'content': '#2 delete'},
|
||||||
|
{'type': 'text', 'role': 'caption', 'content': '失败'},
|
||||||
|
{
|
||||||
|
'type': 'text',
|
||||||
|
'role': 'caption',
|
||||||
|
'content': 'Schedule item not found',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('日历操作完成'), findsOneWidget);
|
||||||
|
expect(find.text('#1 create'), findsOneWidget);
|
||||||
|
expect(find.text('#2 delete'), findsOneWidget);
|
||||||
|
expect(find.text('成功'), findsOneWidget);
|
||||||
|
expect(find.text('失败'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('renders fallback for invalid schema', (tester) async {
|
testWidgets('renders fallback for invalid schema', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from core.agentscope.prompts.agent_prompt import (
|
|||||||
)
|
)
|
||||||
from core.agentscope.prompts.tool_prompt import build_tools_prompt
|
from core.agentscope.prompts.tool_prompt import build_tools_prompt
|
||||||
from schemas.agent.system_agent import AgentType
|
from schemas.agent.system_agent import AgentType
|
||||||
|
from schemas.agent.forwarded_props import ClientTimeContext
|
||||||
from schemas.user.context import UserContext
|
from schemas.user.context import UserContext
|
||||||
|
|
||||||
|
|
||||||
@@ -102,10 +103,14 @@ def _build_env_section(
|
|||||||
*,
|
*,
|
||||||
user_context: UserContext,
|
user_context: UserContext,
|
||||||
now_utc: datetime,
|
now_utc: datetime,
|
||||||
|
runtime_client_time: ClientTimeContext | None,
|
||||||
extra_context: str | None,
|
extra_context: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
settings = _get_attr(user_context, "settings")
|
settings = _get_attr(user_context, "settings")
|
||||||
preferences = _get_user_preferences(user_context)
|
preferences = _get_user_preferences(user_context)
|
||||||
|
timezone_profile = preferences["timezone"]
|
||||||
|
timezone_device = runtime_client_time.device_timezone if runtime_client_time else ""
|
||||||
|
timezone_effective = timezone_device or timezone_profile
|
||||||
privacy = _get_attr(settings, "privacy")
|
privacy = _get_attr(settings, "privacy")
|
||||||
notification = _get_attr(settings, "notification")
|
notification = _get_attr(settings, "notification")
|
||||||
user_id = _get_attr(user_context, "id") or _get_attr(user_context, "user_id")
|
user_id = _get_attr(user_context, "id") or _get_attr(user_context, "user_id")
|
||||||
@@ -117,14 +122,17 @@ def _build_env_section(
|
|||||||
),
|
),
|
||||||
"interface_language": preferences["interface_language"],
|
"interface_language": preferences["interface_language"],
|
||||||
"ai_language": preferences["ai_language"],
|
"ai_language": preferences["ai_language"],
|
||||||
"timezone": preferences["timezone"],
|
"timezone": timezone_effective,
|
||||||
|
"timezone_profile": timezone_profile,
|
||||||
|
"timezone_device": timezone_device,
|
||||||
|
"timezone_effective": timezone_effective,
|
||||||
"country": preferences["country"],
|
"country": preferences["country"],
|
||||||
"system_time_utc": (now_utc or datetime.now(timezone.utc))
|
"system_time_utc": (now_utc or datetime.now(timezone.utc))
|
||||||
.astimezone(timezone.utc)
|
.astimezone(timezone.utc)
|
||||||
.isoformat(),
|
.isoformat(),
|
||||||
"system_time_local": _resolve_local_time(
|
"system_time_local": _resolve_local_time(
|
||||||
now_utc=now_utc,
|
now_utc=now_utc,
|
||||||
timezone_name=preferences["timezone"],
|
timezone_name=timezone_effective,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +146,7 @@ def _build_env_section(
|
|||||||
"- Latest explicit user request overrides defaults.",
|
"- Latest explicit user request overrides defaults.",
|
||||||
f"- Response language default: ai_language={preferences['ai_language']}.",
|
f"- Response language default: ai_language={preferences['ai_language']}.",
|
||||||
f"- UI labels and short actions default: interface_language={preferences['interface_language']}.",
|
f"- UI labels and short actions default: interface_language={preferences['interface_language']}.",
|
||||||
f"- Resolve ambiguous dates/times with timezone={preferences['timezone']} and system_time_local.",
|
f"- Resolve ambiguous dates/times with timezone_effective={timezone_effective} and system_time_local.",
|
||||||
f"- Use country={preferences['country']} only when locale is unspecified.",
|
f"- Use country={preferences['country']} only when locale is unspecified.",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -190,6 +198,7 @@ def build_system_prompt(
|
|||||||
agent_type: AgentType,
|
agent_type: AgentType,
|
||||||
user_context: UserContext,
|
user_context: UserContext,
|
||||||
now_utc: datetime,
|
now_utc: datetime,
|
||||||
|
runtime_client_time: ClientTimeContext | None = None,
|
||||||
extra_context: str | None = None,
|
extra_context: str | None = None,
|
||||||
tools: Sequence[Tool | dict[str, Any]] | None = None,
|
tools: Sequence[Tool | dict[str, Any]] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -198,6 +207,7 @@ def build_system_prompt(
|
|||||||
_build_env_section(
|
_build_env_section(
|
||||||
user_context=user_context,
|
user_context=user_context,
|
||||||
now_utc=now_utc,
|
now_utc=now_utc,
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
extra_context=extra_context,
|
extra_context=extra_context,
|
||||||
),
|
),
|
||||||
_build_safety_section(),
|
_build_safety_section(),
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ from schemas.agent.runtime_models import (
|
|||||||
WorkerAgentOutputLite,
|
WorkerAgentOutputLite,
|
||||||
resolve_worker_output_model,
|
resolve_worker_output_model,
|
||||||
)
|
)
|
||||||
|
from schemas.agent.forwarded_props import (
|
||||||
|
ClientTimeContext,
|
||||||
|
parse_forwarded_props_client_time,
|
||||||
|
)
|
||||||
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
|
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
|
||||||
from schemas.user import UserContext
|
from schemas.user import UserContext
|
||||||
from services.litellm.service import LiteLLMService
|
from services.litellm.service import LiteLLMService
|
||||||
@@ -70,6 +74,7 @@ class AgentScopeRunner:
|
|||||||
run_input: RunAgentInput,
|
run_input: RunAgentInput,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
owner_id = UUID(user_context.id)
|
owner_id = UUID(user_context.id)
|
||||||
|
runtime_client_time = self._resolve_runtime_client_time(run_input=run_input)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
worker_toolkit = self._build_worker_toolkit(
|
worker_toolkit = self._build_worker_toolkit(
|
||||||
@@ -86,6 +91,7 @@ class AgentScopeRunner:
|
|||||||
user_context=user_context,
|
user_context=user_context,
|
||||||
context_messages=context_messages,
|
context_messages=context_messages,
|
||||||
stage_config=router_config,
|
stage_config=router_config,
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
)
|
)
|
||||||
worker_output = await self._execute_worker_step(
|
worker_output = await self._execute_worker_step(
|
||||||
pipeline=pipeline,
|
pipeline=pipeline,
|
||||||
@@ -94,6 +100,7 @@ class AgentScopeRunner:
|
|||||||
router_output=router_output,
|
router_output=router_output,
|
||||||
toolkit=worker_toolkit,
|
toolkit=worker_toolkit,
|
||||||
stage_config=worker_config,
|
stage_config=worker_config,
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -137,6 +144,7 @@ class AgentScopeRunner:
|
|||||||
user_context: UserContext,
|
user_context: UserContext,
|
||||||
context_messages: list[Msg],
|
context_messages: list[Msg],
|
||||||
stage_config: SystemAgentRuntimeConfig,
|
stage_config: SystemAgentRuntimeConfig,
|
||||||
|
runtime_client_time: ClientTimeContext | None,
|
||||||
) -> RouterAgentOutput:
|
) -> RouterAgentOutput:
|
||||||
await self._emit_step_event(
|
await self._emit_step_event(
|
||||||
pipeline=pipeline,
|
pipeline=pipeline,
|
||||||
@@ -149,6 +157,7 @@ class AgentScopeRunner:
|
|||||||
context_messages=context_messages,
|
context_messages=context_messages,
|
||||||
run_input=run_input,
|
run_input=run_input,
|
||||||
stage_config=stage_config,
|
stage_config=stage_config,
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
)
|
)
|
||||||
router_output = RouterAgentOutput.model_validate(router_result.payload)
|
router_output = RouterAgentOutput.model_validate(router_result.payload)
|
||||||
await persist_router_message(
|
await persist_router_message(
|
||||||
@@ -177,6 +186,7 @@ class AgentScopeRunner:
|
|||||||
router_output: RouterAgentOutput,
|
router_output: RouterAgentOutput,
|
||||||
toolkit: Any,
|
toolkit: Any,
|
||||||
stage_config: SystemAgentRuntimeConfig,
|
stage_config: SystemAgentRuntimeConfig,
|
||||||
|
runtime_client_time: ClientTimeContext | None,
|
||||||
) -> WorkerAgentOutputLite:
|
) -> WorkerAgentOutputLite:
|
||||||
worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode)
|
worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode)
|
||||||
await self._emit_step_event(
|
await self._emit_step_event(
|
||||||
@@ -193,6 +203,7 @@ class AgentScopeRunner:
|
|||||||
stage_config=stage_config,
|
stage_config=stage_config,
|
||||||
worker_output_model=worker_output_model,
|
worker_output_model=worker_output_model,
|
||||||
pipeline=pipeline,
|
pipeline=pipeline,
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
)
|
)
|
||||||
worker_output = worker_output_model.model_validate(worker_result.payload)
|
worker_output = worker_output_model.model_validate(worker_result.payload)
|
||||||
await self._emit_step_event(
|
await self._emit_step_event(
|
||||||
@@ -234,12 +245,14 @@ class AgentScopeRunner:
|
|||||||
context_messages: list[Msg],
|
context_messages: list[Msg],
|
||||||
run_input: RunAgentInput,
|
run_input: RunAgentInput,
|
||||||
stage_config: SystemAgentRuntimeConfig,
|
stage_config: SystemAgentRuntimeConfig,
|
||||||
|
runtime_client_time: ClientTimeContext | None,
|
||||||
) -> StageExecutionResult:
|
) -> StageExecutionResult:
|
||||||
tracking_model = self._build_model(stage_config=stage_config)
|
tracking_model = self._build_model(stage_config=stage_config)
|
||||||
system_prompt = build_system_prompt(
|
system_prompt = build_system_prompt(
|
||||||
agent_type=AgentType.ROUTER,
|
agent_type=AgentType.ROUTER,
|
||||||
user_context=user_context,
|
user_context=user_context,
|
||||||
now_utc=datetime.now(timezone.utc),
|
now_utc=datetime.now(timezone.utc),
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
tools=None,
|
tools=None,
|
||||||
)
|
)
|
||||||
response, payload = await finalize_json_response(
|
response, payload = await finalize_json_response(
|
||||||
@@ -281,6 +294,7 @@ class AgentScopeRunner:
|
|||||||
stage_config: SystemAgentRuntimeConfig,
|
stage_config: SystemAgentRuntimeConfig,
|
||||||
worker_output_model: type[WorkerAgentOutputLite],
|
worker_output_model: type[WorkerAgentOutputLite],
|
||||||
pipeline: PipelineLike,
|
pipeline: PipelineLike,
|
||||||
|
runtime_client_time: ClientTimeContext | None,
|
||||||
) -> StageExecutionResult:
|
) -> StageExecutionResult:
|
||||||
worker_input = self._build_worker_input_messages(router_output=router_output)
|
worker_input = self._build_worker_input_messages(router_output=router_output)
|
||||||
tracking_model = self._build_model(stage_config=stage_config)
|
tracking_model = self._build_model(stage_config=stage_config)
|
||||||
@@ -298,6 +312,7 @@ class AgentScopeRunner:
|
|||||||
agent_type=AgentType.WORKER,
|
agent_type=AgentType.WORKER,
|
||||||
user_context=user_context,
|
user_context=user_context,
|
||||||
now_utc=datetime.now(timezone.utc),
|
now_utc=datetime.now(timezone.utc),
|
||||||
|
runtime_client_time=runtime_client_time,
|
||||||
tools=None,
|
tools=None,
|
||||||
),
|
),
|
||||||
toolkit=toolkit,
|
toolkit=toolkit,
|
||||||
@@ -392,5 +407,12 @@ class AgentScopeRunner:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _resolve_runtime_client_time(
|
||||||
|
self, *, run_input: RunAgentInput
|
||||||
|
) -> ClientTimeContext | None:
|
||||||
|
return parse_forwarded_props_client_time(
|
||||||
|
getattr(run_input, "forwarded_props", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
AgentScopeReActRunner = AgentScopeRunner
|
AgentScopeReActRunner = AgentScopeRunner
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from ag_ui.core import RunAgentInput
|
from ag_ui.core import RunAgentInput
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
from schemas.agent.forwarded_props import parse_forwarded_props_client_time
|
||||||
|
|
||||||
MAX_RUN_INPUT_BYTES = 256_000
|
MAX_RUN_INPUT_BYTES = 256_000
|
||||||
MAX_RUN_ID_LENGTH = 128
|
MAX_RUN_ID_LENGTH = 128
|
||||||
@@ -101,6 +102,7 @@ def parse_run_input(payload: dict[str, Any]) -> RunAgentInput:
|
|||||||
raise ValueError("RunAgentInput.messages exceeds limit")
|
raise ValueError("RunAgentInput.messages exceeds limit")
|
||||||
if _user_text_chars(run_input) > MAX_TEXT_CHARS:
|
if _user_text_chars(run_input) > MAX_TEXT_CHARS:
|
||||||
raise ValueError("RunAgentInput user message text exceeds limit")
|
raise ValueError("RunAgentInput user message text exceeds limit")
|
||||||
|
parse_forwarded_props_client_time(getattr(run_input, "forwarded_props", None))
|
||||||
return run_input
|
return run_input
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Annotated, Any, Literal, cast
|
from typing import Annotated, Any, Literal, cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -22,7 +21,8 @@ from core.agentscope.tools.utils.calendar_ui import (
|
|||||||
calendar_write_hints,
|
calendar_write_hints,
|
||||||
dump_tool_output,
|
dump_tool_output,
|
||||||
)
|
)
|
||||||
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
|
||||||
|
from schemas.agent.ui_hints import UiHintListItem, UiHintStatus
|
||||||
from v1.schedule_items.schemas import (
|
from v1.schedule_items.schemas import (
|
||||||
ScheduleItemCreateRequest,
|
ScheduleItemCreateRequest,
|
||||||
ScheduleItemShareRequest,
|
ScheduleItemShareRequest,
|
||||||
@@ -146,85 +146,126 @@ async def calendar_read(
|
|||||||
|
|
||||||
|
|
||||||
async def calendar_write(
|
async def calendar_write(
|
||||||
operation: Annotated[
|
operations: Annotated[
|
||||||
Literal["create", "update", "delete"],
|
list[Literal["create", "update", "delete"]],
|
||||||
Field(description="Write operation: create, update, or delete."),
|
|
||||||
],
|
|
||||||
event_id: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Required event ID for update/delete operations."),
|
|
||||||
] = None,
|
|
||||||
title: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Event title.", max_length=255),
|
|
||||||
] = None,
|
|
||||||
description: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Event description.", max_length=2000),
|
|
||||||
] = None,
|
|
||||||
start_at: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Event start time in ISO 8601 format."),
|
|
||||||
] = None,
|
|
||||||
end_at: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Event end time in ISO 8601 format."),
|
|
||||||
] = None,
|
|
||||||
event_timezone: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="IANA timezone name for the event.", max_length=50),
|
|
||||||
] = None,
|
|
||||||
location: Annotated[str | None, Field(description="Event location.")] = None,
|
|
||||||
color: Annotated[
|
|
||||||
str | None,
|
|
||||||
Field(description="Event color value, for example #4F46E5."),
|
|
||||||
] = None,
|
|
||||||
reminder_minutes: Annotated[
|
|
||||||
int | None,
|
|
||||||
Field(
|
Field(
|
||||||
description="Minutes before start time to trigger reminder (0-10080).",
|
description=(
|
||||||
ge=0,
|
"Batch operations list. Each item must be create, update, or delete."
|
||||||
le=10080,
|
),
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
event_ids: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional event id list aligned with operations. "
|
||||||
|
"Required for update/delete item."
|
||||||
|
)
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
status: Annotated[
|
titles: Annotated[
|
||||||
Literal["active", "completed", "canceled", "archived"] | None,
|
list[str | None] | None,
|
||||||
Field(description="Event status: active, completed, canceled, or archived."),
|
Field(description="Optional title list aligned with operations."),
|
||||||
|
] = None,
|
||||||
|
descriptions: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(description="Optional description list aligned with operations."),
|
||||||
|
] = None,
|
||||||
|
start_ats: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional start time list aligned with operations, ISO 8601 with timezone."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
end_ats: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional end time list aligned with operations, ISO 8601 with timezone."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
event_timezones: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional event timezone list aligned with operations, IANA timezone."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
locations: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(description="Optional location list aligned with operations."),
|
||||||
|
] = None,
|
||||||
|
colors: Annotated[
|
||||||
|
list[str | None] | None,
|
||||||
|
Field(description="Optional color list aligned with operations."),
|
||||||
|
] = None,
|
||||||
|
reminder_minutes_list: Annotated[
|
||||||
|
list[int | None] | None,
|
||||||
|
Field(
|
||||||
|
description=(
|
||||||
|
"Optional reminder minutes list aligned with operations, value range 0-10080."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
statuses: Annotated[
|
||||||
|
list[Literal["active", "completed", "canceled", "archived"] | None] | None,
|
||||||
|
Field(description="Optional status list aligned with operations."),
|
||||||
] = None,
|
] = None,
|
||||||
session: Any = None,
|
session: Any = None,
|
||||||
owner_id: Any = None,
|
owner_id: Any = None,
|
||||||
) -> ToolResponse:
|
) -> ToolResponse:
|
||||||
"""Create, update, or delete a calendar event.
|
"""Batch create/update/delete calendar events using aligned list parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
operation: Write operation type, one of create, update, delete.
|
operations: Operation list. Length defines batch size.
|
||||||
event_id: Target event id for update and delete operations.
|
event_ids: Optional event id list aligned with operations.
|
||||||
title: Event title.
|
titles: Optional title list aligned with operations.
|
||||||
description: Event description.
|
descriptions: Optional description list aligned with operations.
|
||||||
start_at: Event start time in ISO 8601 format.
|
start_ats: Optional start time list aligned with operations.
|
||||||
end_at: Event end time in ISO 8601 format.
|
end_ats: Optional end time list aligned with operations.
|
||||||
event_timezone: IANA timezone string.
|
event_timezones: Optional event timezone list aligned with operations.
|
||||||
location: Event location.
|
locations: Optional location list aligned with operations.
|
||||||
color: Event color in hex format, for example #4F46E5.
|
colors: Optional color list aligned with operations.
|
||||||
reminder_minutes: Reminder lead time in minutes.
|
reminder_minutes_list: Optional reminder minute list aligned with operations.
|
||||||
status: Event status value.
|
statuses: Optional status list aligned with operations.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- All provided list parameters must have the same length as operations.
|
||||||
|
- create item requires start_ats[i] and event_timezones[i].
|
||||||
|
- update/delete item requires event_ids[i].
|
||||||
|
- start/end datetime must include timezone offset.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ToolResponse with serialized ToolAgentOutput payload.
|
ToolResponse with serialized ToolAgentOutput payload.
|
||||||
"""
|
"""
|
||||||
tool_name = "calendar_write"
|
tool_name = "calendar_write"
|
||||||
|
|
||||||
|
def _align_list(name: str, values: list[Any] | None, size: int) -> list[Any | None]:
|
||||||
|
if values is None:
|
||||||
|
return [None] * size
|
||||||
|
if len(values) != size:
|
||||||
|
raise ValueError(f"{name} 长度必须与 operations 一致")
|
||||||
|
return list(values)
|
||||||
|
|
||||||
|
batch_size = len(operations)
|
||||||
tool_call_args = {
|
tool_call_args = {
|
||||||
"operation": operation,
|
"operations": operations,
|
||||||
"event_id": event_id,
|
"event_ids": event_ids,
|
||||||
"title": title,
|
"titles": titles,
|
||||||
"description": description,
|
"descriptions": descriptions,
|
||||||
"start_at": start_at,
|
"start_ats": start_ats,
|
||||||
"end_at": end_at,
|
"end_ats": end_ats,
|
||||||
"event_timezone": event_timezone,
|
"event_timezones": event_timezones,
|
||||||
"location": location,
|
"locations": locations,
|
||||||
"color": color,
|
"colors": colors,
|
||||||
"reminder_minutes": reminder_minutes,
|
"reminder_minutes_list": reminder_minutes_list,
|
||||||
"status": status,
|
"statuses": statuses,
|
||||||
}
|
}
|
||||||
runtime_error = _validate_runtime_context(
|
runtime_error = _validate_runtime_context(
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
@@ -239,145 +280,237 @@ async def calendar_write(
|
|||||||
service = create_schedule_service(
|
service = create_schedule_service(
|
||||||
cast(AsyncSession, session), cast(UUID, owner_id)
|
cast(AsyncSession, session), cast(UUID, owner_id)
|
||||||
)
|
)
|
||||||
|
aligned_event_ids = _align_list("event_ids", event_ids, batch_size)
|
||||||
if operation == "create":
|
aligned_titles = _align_list("titles", titles, batch_size)
|
||||||
parsed_start = parse_iso_datetime(start_at) if start_at else None
|
aligned_descriptions = _align_list("descriptions", descriptions, batch_size)
|
||||||
if parsed_start is None:
|
aligned_start_ats = _align_list("start_ats", start_ats, batch_size)
|
||||||
parsed_start = datetime.now(timezone.utc) + timedelta(hours=1)
|
aligned_end_ats = _align_list("end_ats", end_ats, batch_size)
|
||||||
parsed_end = parse_iso_datetime(end_at) if end_at else None
|
aligned_event_timezones = _align_list(
|
||||||
tz = (
|
"event_timezones", event_timezones, batch_size
|
||||||
event_timezone.strip()
|
|
||||||
if event_timezone and event_timezone.strip()
|
|
||||||
else "Asia/Shanghai"
|
|
||||||
)
|
)
|
||||||
|
aligned_locations = _align_list("locations", locations, batch_size)
|
||||||
|
aligned_colors = _align_list("colors", colors, batch_size)
|
||||||
|
aligned_reminders = _align_list(
|
||||||
|
"reminder_minutes_list", reminder_minutes_list, batch_size
|
||||||
|
)
|
||||||
|
aligned_statuses = _align_list("statuses", statuses, batch_size)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
result_items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for idx, operation in enumerate(operations):
|
||||||
|
event_id = aligned_event_ids[idx]
|
||||||
|
title = aligned_titles[idx]
|
||||||
|
description = aligned_descriptions[idx]
|
||||||
|
start_at = aligned_start_ats[idx]
|
||||||
|
end_at = aligned_end_ats[idx]
|
||||||
|
event_timezone = aligned_event_timezones[idx]
|
||||||
|
location = aligned_locations[idx]
|
||||||
|
color = aligned_colors[idx]
|
||||||
|
reminder_minutes = aligned_reminders[idx]
|
||||||
|
status = aligned_statuses[idx]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if operation == "create":
|
||||||
|
if start_at is None or not start_at.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"创建日程需要提供 start_at,且必须包含时区偏移"
|
||||||
|
)
|
||||||
|
if event_timezone is None or not event_timezone.strip():
|
||||||
|
raise ValueError("创建日程需要提供 event_timezone")
|
||||||
|
parsed_start = parse_iso_datetime(start_at)
|
||||||
|
if parsed_start is None:
|
||||||
|
raise ValueError(
|
||||||
|
"创建日程需要提供 start_at,且必须包含时区偏移"
|
||||||
|
)
|
||||||
|
parsed_end = parse_iso_datetime(end_at) if end_at else None
|
||||||
created = await service.create_agent_generated(
|
created = await service.create_agent_generated(
|
||||||
ScheduleItemCreateRequest(
|
ScheduleItemCreateRequest(
|
||||||
title=title.strip() if title and title.strip() else "新的日程",
|
title=title.strip()
|
||||||
|
if title and title.strip()
|
||||||
|
else "新的日程",
|
||||||
description=description.strip()
|
description=description.strip()
|
||||||
if description and description.strip()
|
if description and description.strip()
|
||||||
else None,
|
else None,
|
||||||
start_at=parsed_start,
|
start_at=parsed_start,
|
||||||
end_at=parsed_end,
|
end_at=parsed_end,
|
||||||
timezone=tz,
|
timezone=event_timezone.strip(),
|
||||||
metadata=build_schedule_metadata(location, color, reminder_minutes),
|
metadata=build_schedule_metadata(
|
||||||
)
|
location,
|
||||||
)
|
color,
|
||||||
event_dict = schedule_event_to_dict(created)
|
cast(int | None, reminder_minutes),
|
||||||
summary = f"日程「{created.title}」已创建"
|
|
||||||
return dump_tool_output(
|
|
||||||
ToolAgentOutput(
|
|
||||||
tool_name=tool_name,
|
|
||||||
tool_call_id=f"{tool_name}-call",
|
|
||||||
tool_call_args=tool_call_args,
|
|
||||||
status=ToolStatus.SUCCESS,
|
|
||||||
result_summary=summary,
|
|
||||||
ui_hints=calendar_write_hints(
|
|
||||||
operation="create",
|
|
||||||
message=summary,
|
|
||||||
event=event_dict,
|
|
||||||
event_id=event_id,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
success_count += 1
|
||||||
|
result_items.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"operation": operation,
|
||||||
|
"status": "success",
|
||||||
|
"eventId": str(created.id),
|
||||||
|
"message": f"日程「{created.title}」已创建",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if operation == "update":
|
if operation == "update":
|
||||||
if not event_id:
|
if event_id is None or not event_id.strip():
|
||||||
return calendar_error_output(
|
raise ValueError("更新日程需要提供 event_id")
|
||||||
tool_name=tool_name,
|
|
||||||
tool_call_args=tool_call_args,
|
|
||||||
code="INVALID_ARGUMENT",
|
|
||||||
message="更新日程需要提供 event_id",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
parsed_event_id = UUID(event_id)
|
parsed_event_id = UUID(event_id)
|
||||||
update_data: dict[str, Any] = {}
|
update_data: dict[str, Any] = {}
|
||||||
if title:
|
if title is not None:
|
||||||
update_data["title"] = title.strip()
|
update_data["title"] = title.strip()
|
||||||
if description:
|
if description is not None:
|
||||||
update_data["description"] = description.strip()
|
update_data["description"] = description.strip()
|
||||||
if start_at:
|
if start_at:
|
||||||
update_data["start_at"] = parse_iso_datetime(start_at)
|
update_data["start_at"] = parse_iso_datetime(start_at)
|
||||||
if end_at:
|
if end_at:
|
||||||
update_data["end_at"] = parse_iso_datetime(end_at)
|
update_data["end_at"] = parse_iso_datetime(end_at)
|
||||||
if event_timezone:
|
if event_timezone is not None:
|
||||||
update_data["timezone"] = event_timezone.strip()
|
timezone_value = event_timezone.strip()
|
||||||
|
if not timezone_value:
|
||||||
|
raise ValueError("event_timezone 不能为空")
|
||||||
|
update_data["timezone"] = timezone_value
|
||||||
if status:
|
if status:
|
||||||
try:
|
|
||||||
update_data["status"] = ScheduleItemStatus(status)
|
update_data["status"] = ScheduleItemStatus(status)
|
||||||
except ValueError:
|
|
||||||
return calendar_error_output(
|
|
||||||
tool_name=tool_name,
|
|
||||||
tool_call_args=tool_call_args,
|
|
||||||
code="INVALID_ARGUMENT",
|
|
||||||
message="status 必须是 active, completed, canceled, archived 之一",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
if location or color or reminder_minutes is not None:
|
if location or color or reminder_minutes is not None:
|
||||||
existing = await service.get_by_id(parsed_event_id)
|
existing = await service.get_by_id(parsed_event_id)
|
||||||
update_data["metadata"] = merge_schedule_metadata_for_update(
|
update_data["metadata"] = merge_schedule_metadata_for_update(
|
||||||
existing_metadata=existing.metadata,
|
existing_metadata=existing.metadata,
|
||||||
location=location,
|
location=cast(str | None, location),
|
||||||
color=color,
|
color=cast(str | None, color),
|
||||||
reminder_minutes=reminder_minutes,
|
reminder_minutes=cast(int | None, reminder_minutes),
|
||||||
)
|
)
|
||||||
|
|
||||||
updated = await service.update(
|
updated = await service.update(
|
||||||
parsed_event_id, ScheduleItemUpdateRequest.model_validate(update_data)
|
parsed_event_id,
|
||||||
)
|
ScheduleItemUpdateRequest.model_validate(update_data),
|
||||||
event_dict = schedule_event_to_dict(updated)
|
|
||||||
summary = f"日程「{updated.title}」已更新"
|
|
||||||
return dump_tool_output(
|
|
||||||
ToolAgentOutput(
|
|
||||||
tool_name=tool_name,
|
|
||||||
tool_call_id=f"{tool_name}-call",
|
|
||||||
tool_call_args=tool_call_args,
|
|
||||||
status=ToolStatus.SUCCESS,
|
|
||||||
result_summary=summary,
|
|
||||||
ui_hints=calendar_write_hints(
|
|
||||||
operation="update",
|
|
||||||
message=summary,
|
|
||||||
event=event_dict,
|
|
||||||
event_id=event_id,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
success_count += 1
|
||||||
|
result_items.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"operation": operation,
|
||||||
|
"status": "success",
|
||||||
|
"eventId": str(updated.id),
|
||||||
|
"message": f"日程「{updated.title}」已更新",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if operation == "delete":
|
if operation == "delete":
|
||||||
if not event_id:
|
if event_id is None or not event_id.strip():
|
||||||
return calendar_error_output(
|
raise ValueError("删除日程需要提供 event_id")
|
||||||
tool_name=tool_name,
|
|
||||||
tool_call_args=tool_call_args,
|
|
||||||
code="INVALID_ARGUMENT",
|
|
||||||
message="删除日程需要提供 event_id",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
await service.delete(UUID(event_id))
|
await service.delete(UUID(event_id))
|
||||||
summary = f"日程 {event_id} 已删除"
|
success_count += 1
|
||||||
|
result_items.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"operation": operation,
|
||||||
|
"status": "success",
|
||||||
|
"eventId": event_id,
|
||||||
|
"message": f"日程 {event_id} 已删除",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
code, message, _ = map_calendar_exception(exc)
|
||||||
|
failed_count += 1
|
||||||
|
result_items.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"operation": operation,
|
||||||
|
"status": "failure",
|
||||||
|
"eventId": event_id,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if failed_count == 0:
|
||||||
|
final_status = ToolStatus.SUCCESS
|
||||||
|
ui_status = UiHintStatus.SUCCESS
|
||||||
|
summary = f"日程批量操作完成,共 {batch_size} 条,成功 {success_count} 条"
|
||||||
|
elif success_count == 0:
|
||||||
|
final_status = ToolStatus.FAILURE
|
||||||
|
ui_status = UiHintStatus.ERROR
|
||||||
|
summary = f"日程批量操作失败,共 {batch_size} 条,失败 {failed_count} 条"
|
||||||
|
else:
|
||||||
|
final_status = ToolStatus.PARTIAL
|
||||||
|
ui_status = UiHintStatus.WARNING
|
||||||
|
summary = f"日程批量操作部分成功,共 {batch_size} 条,成功 {success_count} 条,失败 {failed_count} 条"
|
||||||
|
|
||||||
|
error_info: ErrorInfo | None = None
|
||||||
|
if final_status == ToolStatus.FAILURE:
|
||||||
|
first_failure = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in result_items
|
||||||
|
if isinstance(item, dict) and item.get("status") == "failure"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
error_info = ErrorInfo(
|
||||||
|
code=str(
|
||||||
|
first_failure.get("code") if first_failure else "BATCH_FAILED"
|
||||||
|
),
|
||||||
|
message=str(first_failure.get("message") if first_failure else summary),
|
||||||
|
retryable=False,
|
||||||
|
details={"results": result_items},
|
||||||
|
)
|
||||||
|
|
||||||
|
result_list_items = [
|
||||||
|
UiHintListItem(
|
||||||
|
id=(
|
||||||
|
str(item.get("eventId"))
|
||||||
|
if isinstance(item, dict) and item.get("eventId") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
title=(
|
||||||
|
f"#{int(item.get('index', 0)) + 1} {str(item.get('operation', 'unknown'))}"
|
||||||
|
if isinstance(item, dict)
|
||||||
|
else "unknown"
|
||||||
|
),
|
||||||
|
subtitle=(
|
||||||
|
"成功"
|
||||||
|
if isinstance(item, dict) and item.get("status") == "success"
|
||||||
|
else "失败"
|
||||||
|
),
|
||||||
|
description=(
|
||||||
|
str(item.get("message") or "") if isinstance(item, dict) else ""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in result_items
|
||||||
|
]
|
||||||
|
|
||||||
return dump_tool_output(
|
return dump_tool_output(
|
||||||
ToolAgentOutput(
|
ToolAgentOutput(
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
tool_call_id=f"{tool_name}-call",
|
tool_call_id=f"{tool_name}-call",
|
||||||
tool_call_args=tool_call_args,
|
tool_call_args=tool_call_args,
|
||||||
status=ToolStatus.SUCCESS,
|
status=final_status,
|
||||||
result_summary=summary,
|
result_summary=summary,
|
||||||
|
error=error_info,
|
||||||
ui_hints=calendar_write_hints(
|
ui_hints=calendar_write_hints(
|
||||||
operation="delete",
|
operation="batch",
|
||||||
message=summary,
|
message=summary,
|
||||||
event=None,
|
event=None,
|
||||||
event_id=event_id,
|
event_id=None,
|
||||||
|
status=ui_status,
|
||||||
|
).model_copy(
|
||||||
|
update={
|
||||||
|
"list_items": result_list_items,
|
||||||
|
"meta": {
|
||||||
|
"total": batch_size,
|
||||||
|
"success": success_count,
|
||||||
|
"failed": failed_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return calendar_error_output(
|
|
||||||
tool_name=tool_name,
|
|
||||||
tool_call_args=tool_call_args,
|
|
||||||
code="INVALID_ARGUMENT",
|
|
||||||
message="无效的操作类型",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
code, message, retryable = map_calendar_exception(exc)
|
code, message, retryable = map_calendar_exception(exc)
|
||||||
return calendar_error_output(
|
return calendar_error_output(
|
||||||
|
|||||||
@@ -118,11 +118,11 @@ def parse_iso_datetime(value: str | None) -> datetime | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("时间格式必须是 ISO8601 且包含时区偏移") from exc
|
||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
raise ValueError("时间必须包含时区信息")
|
||||||
return parsed.astimezone(timezone.utc)
|
return parsed.astimezone(timezone.utc)
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str]:
|
def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str]:
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def calendar_read_hints(
|
|||||||
UiHintKvItem(key="page_size", label="每页", value=page_size),
|
UiHintKvItem(key="page_size", label="每页", value=page_size),
|
||||||
UiHintKvItem(key="total_pages", label="总页数", value=total_pages),
|
UiHintKvItem(key="total_pages", label="总页数", value=total_pages),
|
||||||
],
|
],
|
||||||
list_items=event_items,
|
listItems=event_items,
|
||||||
actions=[
|
actions=[
|
||||||
UiHintAction(
|
UiHintAction(
|
||||||
label="打开日历",
|
label="打开日历",
|
||||||
@@ -97,6 +97,7 @@ def calendar_write_hints(
|
|||||||
message: str,
|
message: str,
|
||||||
event: dict[str, Any] | None,
|
event: dict[str, Any] | None,
|
||||||
event_id: str | None,
|
event_id: str | None,
|
||||||
|
status: UiHintStatus = UiHintStatus.SUCCESS,
|
||||||
) -> UiHintsPayload:
|
) -> UiHintsPayload:
|
||||||
kv_items: list[UiHintKvItem] = []
|
kv_items: list[UiHintKvItem] = []
|
||||||
|
|
||||||
@@ -126,10 +127,10 @@ def calendar_write_hints(
|
|||||||
|
|
||||||
return UiHintsPayload(
|
return UiHintsPayload(
|
||||||
intent=UiHintIntent.STATUS,
|
intent=UiHintIntent.STATUS,
|
||||||
status=UiHintStatus.SUCCESS,
|
status=status,
|
||||||
title="日历操作完成",
|
title="日历操作完成",
|
||||||
body=message,
|
body=message,
|
||||||
items=kv_items if kv_items else None,
|
items=kv_items,
|
||||||
actions=[
|
actions=[
|
||||||
UiHintAction(
|
UiHintAction(
|
||||||
label="查看日历",
|
label="查看日历",
|
||||||
@@ -159,7 +160,5 @@ def calendar_share_hints(
|
|||||||
UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True),
|
UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True),
|
||||||
UiHintKvItem(key="permission", label="权限", value=permission_text),
|
UiHintKvItem(key="permission", label="权限", value=permission_text),
|
||||||
],
|
],
|
||||||
list_items=[UiHintListItem(title=email) for email in invited]
|
listItems=[UiHintListItem(title=email) for email in invited] if invited else [],
|
||||||
if invited
|
|
||||||
else [],
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from schemas.agent.forwarded_props import (
|
||||||
|
ClientTimeContext,
|
||||||
|
parse_forwarded_props_client_time,
|
||||||
|
)
|
||||||
from schemas.agent.runtime_models import (
|
from schemas.agent.runtime_models import (
|
||||||
ResultType,
|
ResultType,
|
||||||
RouterAgentOutput,
|
RouterAgentOutput,
|
||||||
@@ -22,6 +26,7 @@ from schemas.agent.ui_hints import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentType",
|
"AgentType",
|
||||||
|
"ClientTimeContext",
|
||||||
"ResultType",
|
"ResultType",
|
||||||
"RouterAgentOutput",
|
"RouterAgentOutput",
|
||||||
"RouterUiDecision",
|
"RouterUiDecision",
|
||||||
@@ -39,4 +44,5 @@ __all__ = [
|
|||||||
"WorkerAgentOutputRich",
|
"WorkerAgentOutputRich",
|
||||||
"WorkerAgentOutput",
|
"WorkerAgentOutput",
|
||||||
"resolve_worker_output_model",
|
"resolve_worker_output_model",
|
||||||
|
"parse_forwarded_props_client_time",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
StrictInt,
|
||||||
|
ValidationError,
|
||||||
|
field_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
_RFC3339_WITH_TZ_PATTERN = re.compile(
|
||||||
|
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientTimeContext(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
device_timezone: str = Field(
|
||||||
|
...,
|
||||||
|
description="IANA timezone from client device, e.g. America/Los_Angeles.",
|
||||||
|
)
|
||||||
|
client_now_iso: str = Field(
|
||||||
|
...,
|
||||||
|
description="RFC3339 datetime with timezone offset from client device.",
|
||||||
|
)
|
||||||
|
client_epoch_ms: StrictInt = Field(
|
||||||
|
...,
|
||||||
|
ge=0,
|
||||||
|
description="Unix epoch milliseconds from client device.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("device_timezone")
|
||||||
|
@classmethod
|
||||||
|
def validate_device_timezone(cls, value: str) -> str:
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except ZoneInfoNotFoundError as exc:
|
||||||
|
raise ValueError("invalid client_time.device_timezone") from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("client_now_iso")
|
||||||
|
@classmethod
|
||||||
|
def validate_client_now_iso(cls, value: str) -> str:
|
||||||
|
if not _RFC3339_WITH_TZ_PATTERN.fullmatch(value):
|
||||||
|
raise ValueError("invalid client_time.client_now_iso")
|
||||||
|
normalized = value.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("invalid client_time.client_now_iso") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("invalid client_time.client_now_iso")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardedPropsPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
client_time: ClientTimeContext | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_forwarded_props_client_time(
|
||||||
|
forwarded_props: Any,
|
||||||
|
) -> ClientTimeContext | None:
|
||||||
|
if not isinstance(forwarded_props, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = ForwardedPropsPayload.model_validate(forwarded_props)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ValueError("invalid RunAgentInput.forwardedProps") from exc
|
||||||
|
return payload.client_time
|
||||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||||
|
|
||||||
from schemas.inbox.messages import (
|
from schemas.inbox.messages import (
|
||||||
CalendarContent,
|
CalendarContent,
|
||||||
@@ -49,9 +50,27 @@ class ScheduleItemCreateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=2000)
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
start_at: datetime
|
start_at: datetime
|
||||||
end_at: datetime | None = None
|
end_at: datetime | None = None
|
||||||
timezone: str = Field(default="UTC", max_length=50)
|
timezone: str = Field(..., min_length=1, max_length=50)
|
||||||
metadata: ScheduleItemMetadata | None = None
|
metadata: ScheduleItemMetadata | None = None
|
||||||
|
|
||||||
|
@field_validator("timezone")
|
||||||
|
@classmethod
|
||||||
|
def validate_timezone(cls, value: str) -> str:
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except ZoneInfoNotFoundError as exc:
|
||||||
|
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("start_at", "end_at")
|
||||||
|
@classmethod
|
||||||
|
def validate_datetime_tzinfo(cls, value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise ValueError("datetime must include timezone offset")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ScheduleItemUpdateRequest(BaseModel):
|
class ScheduleItemUpdateRequest(BaseModel):
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
@@ -64,6 +83,26 @@ class ScheduleItemUpdateRequest(BaseModel):
|
|||||||
metadata: ScheduleItemMetadata | None = None
|
metadata: ScheduleItemMetadata | None = None
|
||||||
status: ScheduleItemStatus | None = None
|
status: ScheduleItemStatus | None = None
|
||||||
|
|
||||||
|
@field_validator("timezone")
|
||||||
|
@classmethod
|
||||||
|
def validate_timezone(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except ZoneInfoNotFoundError as exc:
|
||||||
|
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("start_at", "end_at")
|
||||||
|
@classmethod
|
||||||
|
def validate_datetime_tzinfo(cls, value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise ValueError("datetime must include timezone offset")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ScheduleItemResponse(BaseModel):
|
class ScheduleItemResponse(BaseModel):
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
|
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
|
||||||
@@ -99,6 +138,13 @@ class ScheduleItemListRequest(BaseModel):
|
|||||||
start_at: datetime
|
start_at: datetime
|
||||||
end_at: datetime
|
end_at: datetime
|
||||||
|
|
||||||
|
@field_validator("start_at", "end_at")
|
||||||
|
@classmethod
|
||||||
|
def validate_datetime_tzinfo(cls, value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise ValueError("datetime must include timezone offset")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
_PERMISSION_VIEW = 1
|
_PERMISSION_VIEW = 1
|
||||||
_PERMISSION_INVITE = 2
|
_PERMISSION_INVITE = 2
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Protocol, Literal
|
from typing import TYPE_CHECKING, Protocol, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -83,15 +84,18 @@ class ScheduleItemService(BaseService):
|
|||||||
) -> ScheduleItemResponse:
|
) -> ScheduleItemResponse:
|
||||||
user_id = self.require_user_id()
|
user_id = self.require_user_id()
|
||||||
|
|
||||||
if request.end_at and request.end_at <= request.start_at:
|
normalized_start_at = self._to_utc_required(request.start_at)
|
||||||
|
normalized_end_at = self._to_utc(request.end_at)
|
||||||
|
|
||||||
|
if normalized_end_at and normalized_end_at <= normalized_start_at:
|
||||||
raise HTTPException(status_code=400, detail="end_at must be after start_at")
|
raise HTTPException(status_code=400, detail="end_at must be after start_at")
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"owner_id": user_id,
|
"owner_id": user_id,
|
||||||
"title": request.title,
|
"title": request.title,
|
||||||
"description": request.description,
|
"description": request.description,
|
||||||
"start_at": request.start_at,
|
"start_at": normalized_start_at,
|
||||||
"end_at": request.end_at,
|
"end_at": normalized_end_at,
|
||||||
"timezone": request.timezone,
|
"timezone": request.timezone,
|
||||||
"extra_metadata": request.metadata.model_dump() if request.metadata else {},
|
"extra_metadata": request.metadata.model_dump() if request.metadata else {},
|
||||||
"source_type": source_type,
|
"source_type": source_type,
|
||||||
@@ -168,7 +172,18 @@ class ScheduleItemService(BaseService):
|
|||||||
# Validate time range
|
# Validate time range
|
||||||
next_start = update_data.get("start_at", existing.start_at)
|
next_start = update_data.get("start_at", existing.start_at)
|
||||||
next_end = update_data.get("end_at", existing.end_at)
|
next_end = update_data.get("end_at", existing.end_at)
|
||||||
if next_end is not None and next_end <= next_start:
|
if isinstance(next_start, datetime):
|
||||||
|
next_start = self._to_utc_required(next_start)
|
||||||
|
update_data["start_at"] = next_start
|
||||||
|
if isinstance(next_end, datetime):
|
||||||
|
next_end = self._to_utc(next_end)
|
||||||
|
update_data["end_at"] = next_end
|
||||||
|
if next_end is not None:
|
||||||
|
if not isinstance(next_start, datetime):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="start_at must include timezone"
|
||||||
|
)
|
||||||
|
if next_end <= next_start:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="end_at must be after start_at"
|
status_code=400, detail="end_at must be after start_at"
|
||||||
)
|
)
|
||||||
@@ -218,13 +233,16 @@ class ScheduleItemService(BaseService):
|
|||||||
) -> list[ScheduleItemResponse]:
|
) -> list[ScheduleItemResponse]:
|
||||||
user_id = self.require_user_id()
|
user_id = self.require_user_id()
|
||||||
|
|
||||||
if request.end_at <= request.start_at:
|
normalized_start_at = self._to_utc_required(request.start_at)
|
||||||
|
normalized_end_at = self._to_utc_required(request.end_at)
|
||||||
|
|
||||||
|
if normalized_end_at <= normalized_start_at:
|
||||||
raise HTTPException(status_code=400, detail="end_at must be after start_at")
|
raise HTTPException(status_code=400, detail="end_at must be after start_at")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subscribed_items = (
|
subscribed_items = (
|
||||||
await self._repository.list_subscribed_items_by_date_range(
|
await self._repository.list_subscribed_items_by_date_range(
|
||||||
user_id, request.start_at, request.end_at
|
user_id, normalized_start_at, normalized_end_at
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -518,3 +536,18 @@ class ScheduleItemService(BaseService):
|
|||||||
|
|
||||||
if subscriptions:
|
if subscriptions:
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
|
|
||||||
|
def _to_utc(self, dt: datetime | None) -> datetime | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="datetime must include timezone"
|
||||||
|
)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
def _to_utc_required(self, dt: datetime) -> datetime:
|
||||||
|
normalized = self._to_utc(dt)
|
||||||
|
if normalized is None:
|
||||||
|
raise HTTPException(status_code=400, detail="datetime is required")
|
||||||
|
return normalized
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from ag_ui.core import RunAgentInput
|
from ag_ui.core import RunAgentInput
|
||||||
from agentscope.message import Msg
|
from agentscope.message import Msg
|
||||||
@@ -208,3 +210,89 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
|
|||||||
]
|
]
|
||||||
assert result["router"]["ui"]["ui_mode"] == "rich"
|
assert result["router"]["ui"]["ui_mode"] == "rich"
|
||||||
assert result["worker"]["answer"] == "done"
|
assert result["worker"]["answer"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_passes_runtime_client_time_to_router_and_worker(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
runner = AgentScopeRunner()
|
||||||
|
pipeline = _FakePipeline()
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class _CommitSession:
|
||||||
|
async def commit(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agentscope.runtime.runner.AsyncSessionLocal",
|
||||||
|
lambda: _FakeSessionCtx(_CommitSession()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _load_system_agent_config(**kwargs):
|
||||||
|
return SystemAgentRuntimeConfig(
|
||||||
|
agent_type=kwargs["agent_type"],
|
||||||
|
model_code="model-a",
|
||||||
|
llm_config=SystemAgentLLMConfig(
|
||||||
|
temperature=0.1, max_tokens=256, timeout_seconds=30
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner, "_load_system_agent_config", _load_system_agent_config)
|
||||||
|
|
||||||
|
async def _run_router_stage(**kwargs):
|
||||||
|
captured["router_timezone"] = kwargs["runtime_client_time"].device_timezone
|
||||||
|
return StageExecutionResult(
|
||||||
|
message=Msg(name="router", content="", role="assistant"),
|
||||||
|
payload=_router_output(ui_mode=UiMode.NONE).model_dump(mode="json"),
|
||||||
|
response_metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_worker_stage(**kwargs):
|
||||||
|
captured["worker_timezone"] = kwargs["runtime_client_time"].device_timezone
|
||||||
|
return StageExecutionResult(
|
||||||
|
message=Msg(name="worker", content="ok", role="assistant"),
|
||||||
|
payload={
|
||||||
|
"status": "success",
|
||||||
|
"answer": "ok",
|
||||||
|
"key_points": [],
|
||||||
|
"result_type": "direct_answer",
|
||||||
|
"suggested_actions": [],
|
||||||
|
"error": None,
|
||||||
|
},
|
||||||
|
response_metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner, "_run_router_stage", _run_router_stage)
|
||||||
|
monkeypatch.setattr(runner, "_run_worker_stage", _run_worker_stage)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.agentscope.runtime.runner.persist_router_message", AsyncMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
run_input = RunAgentInput.model_validate(
|
||||||
|
{
|
||||||
|
"threadId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"runId": "run-client-time",
|
||||||
|
"state": {},
|
||||||
|
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
|
||||||
|
"tools": [],
|
||||||
|
"context": [],
|
||||||
|
"forwardedProps": {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": 1773658353000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await runner.execute(
|
||||||
|
user_context=_user_context(),
|
||||||
|
context_messages=[],
|
||||||
|
pipeline=pipeline,
|
||||||
|
run_input=run_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["router_timezone"] == "America/Los_Angeles"
|
||||||
|
assert captured["worker_timezone"] == "America/Los_Angeles"
|
||||||
|
|||||||
@@ -157,3 +157,75 @@ def test_parse_run_input_accepts_snake_case_aliases() -> None:
|
|||||||
assert run_input.thread_id == "00000000-0000-0000-0000-000000000001"
|
assert run_input.thread_id == "00000000-0000-0000-0000-000000000001"
|
||||||
assert run_input.run_id == "run-1"
|
assert run_input.run_id == "run-1"
|
||||||
validate_run_request_messages_contract(run_input)
|
validate_run_request_messages_contract(run_input)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_input_accepts_client_time_forwarded_props() -> None:
|
||||||
|
payload = _base_payload()
|
||||||
|
payload["forwardedProps"] = {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": 1773658353000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run_input = parse_run_input(payload)
|
||||||
|
|
||||||
|
assert run_input.forwarded_props is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_input_rejects_invalid_client_time_timezone() -> None:
|
||||||
|
payload = _base_payload()
|
||||||
|
payload["forwardedProps"] = {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "Mars/OlympusMons",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": 1773658353000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
|
||||||
|
parse_run_input(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_input_rejects_invalid_client_time_now_iso() -> None:
|
||||||
|
payload = _base_payload()
|
||||||
|
payload["forwardedProps"] = {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16 09:12:33",
|
||||||
|
"client_epoch_ms": 1773658353000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
|
||||||
|
parse_run_input(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_input_rejects_invalid_client_time_epoch_type() -> None:
|
||||||
|
payload = _base_payload()
|
||||||
|
payload["forwardedProps"] = {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": "1773658353000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
|
||||||
|
parse_run_input(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_input_rejects_unknown_forwarded_props_key() -> None:
|
||||||
|
payload = _base_payload()
|
||||||
|
payload["forwardedProps"] = {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": 1773658353000,
|
||||||
|
},
|
||||||
|
"unexpected": {"foo": "bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
|
||||||
|
parse_run_input(payload)
|
||||||
|
|||||||
@@ -1,189 +1,166 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from agentscope.tool import ToolResponse
|
||||||
|
|
||||||
from core.agentscope.tools.custom import calendar as calendar_module
|
from core.agentscope.tools.custom import calendar as calendar_module
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
|
||||||
async def test_calendar_read_returns_list_payload(
|
assert response.content
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
first = response.content[0]
|
||||||
) -> None:
|
if isinstance(first, dict):
|
||||||
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
|
text = str(first.get("text", ""))
|
||||||
del kwargs
|
else:
|
||||||
return {"type": "calendar_event_list.v1", "version": "v1", "data": {"ok": True}}
|
text = str(getattr(first, "text", ""))
|
||||||
|
return json.loads(text)
|
||||||
monkeypatch.setattr(calendar_module, "_execute_list_calendar_events", _fake_execute)
|
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
|
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
|
||||||
|
|
||||||
result = await calendar_module.calendar_read(
|
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
|
||||||
owner_id=uuid4(),
|
|
||||||
user_token="token-abc",
|
|
||||||
)
|
|
||||||
assert result["type"] == "calendar_event_list.v1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@dataclass
|
||||||
async def test_calendar_read_requires_valid_user_token(
|
class _FakeService:
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
created_request: Any = None
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False)
|
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
|
||||||
|
|
||||||
result = await calendar_module.calendar_read(
|
async def create_agent_generated(self, request):
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
self.created_request = request
|
||||||
owner_id=uuid4(),
|
return SimpleNamespace(
|
||||||
user_token="bad-token",
|
id=uuid4(),
|
||||||
|
title=request.title,
|
||||||
|
description=request.description,
|
||||||
|
start_at=request.start_at,
|
||||||
|
end_at=request.end_at,
|
||||||
|
timezone=request.timezone,
|
||||||
|
metadata=request.metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["data"]["ok"] is False
|
|
||||||
assert result["data"]["code"] == "UNAUTHORIZED"
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_write_requires_runtime_context() -> None:
|
||||||
|
result = await calendar_module.calendar_write(operations=["create"])
|
||||||
|
payload = _decode_tool_response(result)
|
||||||
|
|
||||||
|
assert payload["status"] == "failure"
|
||||||
|
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calendar_write_maps_event_id_for_update(
|
async def test_calendar_write_create_requires_start_at(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
fake_service = _FakeService()
|
||||||
|
|
||||||
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
|
|
||||||
captured.update(cast(dict[str, object], kwargs["tool_args"]))
|
|
||||||
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
calendar_module, "_execute_mutate_calendar_event", _fake_execute
|
calendar_module, "create_schedule_service", lambda *_: fake_service
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
|
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
|
||||||
|
|
||||||
result = await calendar_module.calendar_write(
|
result = await calendar_module.calendar_write(
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
operations=["create"],
|
||||||
|
event_timezones=["Asia/Shanghai"],
|
||||||
|
session=SimpleNamespace(),
|
||||||
owner_id=uuid4(),
|
owner_id=uuid4(),
|
||||||
user_token="token-abc",
|
|
||||||
operation="update",
|
|
||||||
event_id=str(uuid4()),
|
|
||||||
title="新标题",
|
|
||||||
)
|
)
|
||||||
assert result["type"] == "calendar_card.v1"
|
payload = _decode_tool_response(result)
|
||||||
assert captured["operation"] == "update"
|
|
||||||
assert "eventId" in captured
|
assert payload["status"] == "failure"
|
||||||
|
assert payload["error"]["code"] == "INVALID_ARGUMENT"
|
||||||
|
assert "start_at" in payload["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calendar_write_maps_reminder_minutes(
|
async def test_calendar_write_create_requires_event_timezone(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
fake_service = _FakeService()
|
||||||
|
|
||||||
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
|
|
||||||
captured.update(cast(dict[str, object], kwargs["tool_args"]))
|
|
||||||
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
calendar_module, "_execute_mutate_calendar_event", _fake_execute
|
calendar_module, "create_schedule_service", lambda *_: fake_service
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
|
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
|
||||||
|
|
||||||
await calendar_module.calendar_write(
|
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
|
||||||
owner_id=uuid4(),
|
|
||||||
user_token="token-abc",
|
|
||||||
operation="create",
|
|
||||||
reminder_minutes=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert captured["reminderMinutes"] == 15
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_calendar_write_returns_failed_tool_response_on_error(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
|
|
||||||
del kwargs
|
|
||||||
raise ValueError("eventId is required")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
calendar_module, "_execute_mutate_calendar_event", _fake_execute
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
|
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
|
||||||
|
|
||||||
result = await calendar_module.calendar_write(
|
result = await calendar_module.calendar_write(
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
operations=["create"],
|
||||||
|
start_ats=["2026-03-16T09:00:00+08:00"],
|
||||||
|
session=SimpleNamespace(),
|
||||||
owner_id=uuid4(),
|
owner_id=uuid4(),
|
||||||
user_token="token-abc",
|
|
||||||
operation="update",
|
|
||||||
)
|
)
|
||||||
|
payload = _decode_tool_response(result)
|
||||||
|
|
||||||
assert result["type"] == "calendar_operation.v1"
|
assert payload["status"] == "failure"
|
||||||
assert result["data"]["ok"] is False
|
assert payload["error"]["code"] == "INVALID_ARGUMENT"
|
||||||
assert result["data"]["code"] == "INVALID_ARGUMENT"
|
assert "event_timezone" in payload["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calendar_share_maps_arguments(
|
async def test_calendar_write_rejects_naive_start_at(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
fake_service = _FakeService()
|
||||||
|
monkeypatch.setattr(
|
||||||
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
|
calendar_module, "create_schedule_service", lambda *_: fake_service
|
||||||
captured.update(cast(dict[str, object], kwargs["tool_args"]))
|
|
||||||
return {
|
|
||||||
"type": "calendar_operation.v1",
|
|
||||||
"version": "v1",
|
|
||||||
"data": {"operation": "share", "ok": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(calendar_module, "_execute_share_calendar_event", _fake_execute)
|
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
|
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
|
||||||
|
|
||||||
result = await calendar_module.calendar_share(
|
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
|
||||||
owner_id=uuid4(),
|
|
||||||
user_token="token-abc",
|
|
||||||
event_id=str(uuid4()),
|
|
||||||
invite_user_emails=["a@example.com"],
|
|
||||||
invite_user_names=["alice"],
|
|
||||||
invite_user_ids=[str(uuid4())],
|
|
||||||
invite_permission_view=True,
|
|
||||||
invite_permission_edit=True,
|
|
||||||
invite_permission_invite=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == "calendar_operation.v1"
|
result = await calendar_module.calendar_write(
|
||||||
assert captured["eventId"]
|
operations=["create"],
|
||||||
assert captured["inviteUserEmails"] == ["a@example.com"]
|
start_ats=["2026-03-16T09:00:00"],
|
||||||
assert captured["inviteUserNames"] == ["alice"]
|
event_timezones=["Asia/Shanghai"],
|
||||||
assert isinstance(captured["inviteUserIds"], list)
|
session=SimpleNamespace(),
|
||||||
assert captured["invitePermissionView"] is True
|
owner_id=uuid4(),
|
||||||
assert captured["invitePermissionEdit"] is True
|
)
|
||||||
assert captured["invitePermissionInvite"] is True
|
payload = _decode_tool_response(result)
|
||||||
|
|
||||||
|
assert payload["status"] == "failure"
|
||||||
|
assert payload["error"]["code"] == "INVALID_ARGUMENT"
|
||||||
|
assert "时区" in payload["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calendar_share_requires_valid_user_token(
|
async def test_calendar_write_create_normalizes_to_utc(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False)
|
fake_service = _FakeService()
|
||||||
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
|
monkeypatch.setattr(
|
||||||
|
calendar_module, "create_schedule_service", lambda *_: fake_service
|
||||||
result = await calendar_module.calendar_share(
|
|
||||||
session=cast(AsyncSession, SimpleNamespace()),
|
|
||||||
owner_id=uuid4(),
|
|
||||||
user_token="bad-token",
|
|
||||||
event_id=str(uuid4()),
|
|
||||||
invite_user_emails=["a@example.com"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["data"]["ok"] is False
|
result = await calendar_module.calendar_write(
|
||||||
assert result["data"]["code"] == "UNAUTHORIZED"
|
operations=["create"],
|
||||||
|
titles=["晨会"],
|
||||||
|
start_ats=["2026-03-16T09:00:00+08:00"],
|
||||||
|
end_ats=["2026-03-16T10:00:00+08:00"],
|
||||||
|
event_timezones=["Asia/Shanghai"],
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
)
|
||||||
|
payload = _decode_tool_response(result)
|
||||||
|
|
||||||
|
assert payload["status"] == "success"
|
||||||
|
assert fake_service.created_request is not None
|
||||||
|
request = fake_service.created_request
|
||||||
|
assert request.timezone == "Asia/Shanghai"
|
||||||
|
assert request.start_at == datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc)
|
||||||
|
assert request.end_at == datetime(2026, 3, 16, 2, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_write_rejects_misaligned_batch_lists(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
fake_service = _FakeService()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
calendar_module, "create_schedule_service", lambda *_: fake_service
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await calendar_module.calendar_write(
|
||||||
|
operations=["create", "delete"],
|
||||||
|
start_ats=["2026-03-16T09:00:00+08:00"],
|
||||||
|
event_timezones=["Asia/Shanghai", "Asia/Shanghai"],
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
)
|
||||||
|
payload = _decode_tool_response(result)
|
||||||
|
|
||||||
|
assert payload["status"] == "failure"
|
||||||
|
assert payload["error"]["code"] == "INVALID_ARGUMENT"
|
||||||
|
assert "长度必须与 operations 一致" in payload["error"]["message"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from core.agentscope.prompts.system_prompt import (
|
|||||||
_build_env_section,
|
_build_env_section,
|
||||||
build_system_prompt,
|
build_system_prompt,
|
||||||
)
|
)
|
||||||
|
from schemas.agent.forwarded_props import ClientTimeContext
|
||||||
from schemas.agent.system_agent import AgentType
|
from schemas.agent.system_agent import AgentType
|
||||||
from schemas.user.context import UserContext, parse_profile_settings
|
from schemas.user.context import UserContext, parse_profile_settings
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ def test_build_env_section_uses_balanced_runtime_context_structure() -> None:
|
|||||||
section = _build_env_section(
|
section = _build_env_section(
|
||||||
user_context=_build_user_context(),
|
user_context=_build_user_context(),
|
||||||
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
runtime_client_time=None,
|
||||||
extra_context=None,
|
extra_context=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ def test_build_env_section_uses_balanced_runtime_context_structure() -> None:
|
|||||||
assert "Response language default: ai_language=zh-CN." in section
|
assert "Response language default: ai_language=zh-CN." in section
|
||||||
assert "UI labels and short actions default: interface_language=zh-CN." in section
|
assert "UI labels and short actions default: interface_language=zh-CN." in section
|
||||||
assert (
|
assert (
|
||||||
"Resolve ambiguous dates/times with timezone=Asia/Shanghai and system_time_local."
|
"Resolve ambiguous dates/times with timezone_effective=Asia/Shanghai and system_time_local."
|
||||||
in section
|
in section
|
||||||
)
|
)
|
||||||
assert "Use country=CN only when locale is unspecified." in section
|
assert "Use country=CN only when locale is unspecified." in section
|
||||||
@@ -59,6 +61,7 @@ def test_build_env_section_omits_removed_redundant_contract_phrasing() -> None:
|
|||||||
section = _build_env_section(
|
section = _build_env_section(
|
||||||
user_context=_build_user_context(),
|
user_context=_build_user_context(),
|
||||||
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
runtime_client_time=None,
|
||||||
extra_context=None,
|
extra_context=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ def test_build_env_section_includes_optional_privacy_and_notification_hints() ->
|
|||||||
section = _build_env_section(
|
section = _build_env_section(
|
||||||
user_context=user_context,
|
user_context=user_context,
|
||||||
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
runtime_client_time=None,
|
||||||
extra_context="runtime flag: mobile-client",
|
extra_context="runtime flag: mobile-client",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,6 +109,27 @@ def test_build_env_section_includes_optional_privacy_and_notification_hints() ->
|
|||||||
assert '"system_time_local":"2026-03-11T01:00:00+01:00"' in section
|
assert '"system_time_local":"2026-03-11T01:00:00+01:00"' in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_env_section_prefers_device_timezone_when_present() -> None:
|
||||||
|
section = _build_env_section(
|
||||||
|
user_context=_build_user_context(timezone_name="Asia/Shanghai"),
|
||||||
|
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
|
||||||
|
runtime_client_time=ClientTimeContext(
|
||||||
|
device_timezone="America/Los_Angeles",
|
||||||
|
client_now_iso="2026-03-10T17:00:00-07:00",
|
||||||
|
client_epoch_ms=1773658353000,
|
||||||
|
),
|
||||||
|
extra_context=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert '"timezone_profile":"Asia/Shanghai"' in section
|
||||||
|
assert '"timezone_device":"America/Los_Angeles"' in section
|
||||||
|
assert '"timezone_effective":"America/Los_Angeles"' in section
|
||||||
|
assert (
|
||||||
|
"Resolve ambiguous dates/times with timezone_effective=America/Los_Angeles"
|
||||||
|
in section
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_system_prompt_keeps_sections_focused_without_language_duplication() -> (
|
def test_build_system_prompt_keeps_sections_focused_without_language_duplication() -> (
|
||||||
None
|
None
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def test_create_request_valid() -> None:
|
|||||||
request = ScheduleItemCreateRequest(
|
request = ScheduleItemCreateRequest(
|
||||||
title="Test Event",
|
title="Test Event",
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
assert request.title == "Test Event"
|
assert request.title == "Test Event"
|
||||||
assert request.timezone == "UTC"
|
assert request.timezone == "UTC"
|
||||||
@@ -26,6 +27,7 @@ def test_create_request_with_end_at() -> None:
|
|||||||
title="Test Event",
|
title="Test Event",
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
end_at=datetime(2026, 2, 28, 17, 30, 0, tzinfo=timezone.utc),
|
end_at=datetime(2026, 2, 28, 17, 30, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
assert request.end_at is not None
|
assert request.end_at is not None
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def test_create_request_invalid_title_empty() -> None:
|
|||||||
ScheduleItemCreateRequest(
|
ScheduleItemCreateRequest(
|
||||||
title="",
|
title="",
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ def test_create_request_invalid_title_too_long() -> None:
|
|||||||
ScheduleItemCreateRequest(
|
ScheduleItemCreateRequest(
|
||||||
title="x" * 256,
|
title="x" * 256,
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ def test_create_request_with_metadata() -> None:
|
|||||||
request = ScheduleItemCreateRequest(
|
request = ScheduleItemCreateRequest(
|
||||||
title="Test Event",
|
title="Test Event",
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
assert request.metadata is not None
|
assert request.metadata is not None
|
||||||
@@ -68,6 +73,24 @@ def test_update_request_partial() -> None:
|
|||||||
assert request.description is None
|
assert request.description is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_request_rejects_naive_datetime() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ScheduleItemCreateRequest(
|
||||||
|
title="Test Event",
|
||||||
|
start_at=datetime(2026, 2, 28, 16, 0, 0),
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_request_rejects_invalid_timezone() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ScheduleItemCreateRequest(
|
||||||
|
title="Test Event",
|
||||||
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="Mars/OlympusMons",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_attachment_document() -> None:
|
def test_metadata_attachment_document() -> None:
|
||||||
attachment = ScheduleItemMetadataAttachment(
|
attachment = ScheduleItemMetadataAttachment(
|
||||||
name="document.pdf",
|
name="document.pdf",
|
||||||
@@ -95,7 +118,7 @@ def test_metadata_rejects_invalid_color() -> None:
|
|||||||
|
|
||||||
def test_metadata_rejects_invalid_version() -> None:
|
def test_metadata_rejects_invalid_version() -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
ScheduleItemMetadata(version=2)
|
ScheduleItemMetadata.model_validate({"version": 2})
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_rejects_unknown_field() -> None:
|
def test_metadata_rejects_unknown_field() -> None:
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ async def test_create_success(
|
|||||||
request = ScheduleItemCreateRequest(
|
request = ScheduleItemCreateRequest(
|
||||||
title="Test Event",
|
title="Test Event",
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
service = ScheduleItemService(
|
service = ScheduleItemService(
|
||||||
repository=FakeRepo(None),
|
repository=FakeRepo(None),
|
||||||
@@ -171,6 +172,7 @@ async def test_create_invalid_end_at(
|
|||||||
title="Test Event",
|
title="Test Event",
|
||||||
start_at=datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc),
|
||||||
end_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
end_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
)
|
)
|
||||||
service = ScheduleItemService(
|
service = ScheduleItemService(
|
||||||
repository=FakeRepo(None),
|
repository=FakeRepo(None),
|
||||||
@@ -275,6 +277,7 @@ async def test_create_maps_metadata_to_extra_metadata(
|
|||||||
request = ScheduleItemCreateRequest(
|
request = ScheduleItemCreateRequest(
|
||||||
title="Roadmap",
|
title="Roadmap",
|
||||||
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
metadata=ScheduleItemMetadata(
|
metadata=ScheduleItemMetadata(
|
||||||
location="会议室A",
|
location="会议室A",
|
||||||
color="#4F46E5",
|
color="#4F46E5",
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Calendar Timezone Unification Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Eliminate calendar time mismatches by enforcing one end-to-end timezone policy across App input, Agent runtime context, tool execution, and UTC database storage.
|
||||||
|
|
||||||
|
**Architecture:** Keep database schema unchanged (`start_at/end_at TIMESTAMPTZ + timezone`) and enforce strict runtime normalization. Device timezone is injected from `RunAgentInput.forwardedProps`, resolved into a single `effective_timezone`, then written explicitly into tool arguments and persisted as event timezone while timestamps are stored in UTC. Calendar read responses include deterministic event-timezone-rendered values so frontend rendering is stable and no implicit `toLocal()` conversion remains.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI, Pydantic v2, AgentScope runtime/tooling, Flutter (Dart), PostgreSQL TIMESTAMPTZ, pytest, Flutter test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Protocol and Backend Runtime Normalization
|
||||||
|
|
||||||
|
### Task 1: Freeze protocol and timezone precedence contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/protocols/agent/run-agent-input.md`
|
||||||
|
- Create: `docs/protocols/calendar/timezone-policy.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write protocol delta checklist in docs first**
|
||||||
|
|
||||||
|
Document the exact policy:
|
||||||
|
- `event_timezone > device_timezone > profile.timezone > UTC`
|
||||||
|
- `event_timezone` must be present in final tool call
|
||||||
|
- `start_at/end_at` must be timezone-aware
|
||||||
|
- DB stores UTC timestamps and IANA timezone string
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update RunAgentInput protocol with forwardedProps contract**
|
||||||
|
|
||||||
|
Add canonical payload example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"forwardedProps": {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": 1773658353000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add calendar timezone policy protocol doc**
|
||||||
|
|
||||||
|
Include:
|
||||||
|
- accepted datetime formats
|
||||||
|
- explicit error codes
|
||||||
|
- write/read response semantics
|
||||||
|
- DST handling rule
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify docs consistency**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run python -m pytest tests/unit/core/agentscope/test_system_prompt.py -q`
|
||||||
|
Expected: PASS (no protocol-breaking prompt assumptions)
|
||||||
|
|
||||||
|
|
||||||
|
### Task 2: Parse forwarded device time and compute effective timezone
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/core/agentscope/schemas/agui_input.py`
|
||||||
|
- Modify: `backend/src/core/agentscope/runtime/runner.py`
|
||||||
|
- Modify: `backend/src/core/agentscope/prompts/system_prompt.py`
|
||||||
|
- Test: `backend/tests/unit/core/agentscope/test_system_prompt.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for effective timezone resolution**
|
||||||
|
|
||||||
|
Add tests covering:
|
||||||
|
- forwarded `device_timezone` present -> selected
|
||||||
|
- missing forwarded timezone -> fallback profile timezone
|
||||||
|
- invalid forwarded timezone -> fallback profile timezone
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm RED**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -k timezone -v`
|
||||||
|
Expected: FAIL on new assertions
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal runtime context extraction**
|
||||||
|
|
||||||
|
Implement a typed helper in runner path to read:
|
||||||
|
- `run_input.forwarded_props.client_time.device_timezone`
|
||||||
|
- `client_now_iso`
|
||||||
|
- `client_epoch_ms`
|
||||||
|
|
||||||
|
Compute `effective_timezone` using fixed precedence and pass it into `build_system_prompt(...)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Inject effective_timezone into ENV section**
|
||||||
|
|
||||||
|
Update `build_system_prompt` env payload to include:
|
||||||
|
- `timezone_profile`
|
||||||
|
- `timezone_device`
|
||||||
|
- `timezone_effective`
|
||||||
|
|
||||||
|
Update guidance sentence to resolve ambiguous time with `timezone_effective`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to confirm GREEN**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
|
||||||
|
### Task 3: Remove timezone ambiguity and hidden fallbacks from calendar write
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py`
|
||||||
|
- Modify: `backend/src/core/agentscope/tools/custom/calendar.py`
|
||||||
|
- Modify: `backend/src/v1/schedule_items/schemas.py`
|
||||||
|
- Modify: `backend/src/v1/schedule_items/service.py`
|
||||||
|
- Test: `backend/tests/unit/core/agentscope/test_calendar_tools.py`
|
||||||
|
- Test: `backend/tests/unit/v1/schedule_items/test_schemas.py`
|
||||||
|
- Test: `backend/tests/unit/v1/schedule_items/test_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for forbidden naive datetime and required timezone**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
- naive `start_at` rejected
|
||||||
|
- missing `event_timezone` rejected in tool path
|
||||||
|
- parse failure does not fallback to `now + 1h`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm RED**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py -v`
|
||||||
|
Expected: FAIL on new constraints
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement strict parsing and normalization**
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
- `parse_iso_datetime` rejects naive input
|
||||||
|
- remove default `Asia/Shanghai` in tool
|
||||||
|
- remove fallback auto-generated start time
|
||||||
|
- validate IANA timezone and normalize `start_at/end_at` to UTC before persistence
|
||||||
|
|
||||||
|
- [ ] **Step 4: Enforce service-level invariants**
|
||||||
|
|
||||||
|
Service invariant set:
|
||||||
|
- timezone non-empty and valid IANA
|
||||||
|
- `end_at is None or end_at >= start_at`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run backend tests**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py tests/unit/v1/schedule_items/test_service.py tests/integration/test_schedule_items_routes.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
|
||||||
|
### Task 4: Keep DB schema, add non-breaking constraint migration only
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/alembic/versions/20260316_000x_schedule_items_time_constraints.py`
|
||||||
|
- Test: `backend/tests/integration/test_schedule_items_routes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write migration test expectation first**
|
||||||
|
|
||||||
|
Add/extend integration assertion for invalid `end_at < start_at` returning 422.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run integration test to confirm RED**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run pytest tests/integration/test_schedule_items_routes.py -k end_at -v`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement migration with CHECK only (no new columns)**
|
||||||
|
|
||||||
|
Migration includes:
|
||||||
|
- `CHECK (end_at IS NULL OR end_at >= start_at)`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run migration + integration test**
|
||||||
|
|
||||||
|
Run: `cd backend && uv run alembic upgrade head && uv run pytest tests/integration/test_schedule_items_routes.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Frontend Deterministic Display and Agent Input Wiring
|
||||||
|
|
||||||
|
### Task 5: Wire device timezone into RunAgentInput forwardedProps
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart`
|
||||||
|
- Modify: `apps/lib/features/chat/data/models/ag_ui_event.dart` (only if serialization helper is needed)
|
||||||
|
- Test: `apps/test/features/chat/ag_ui_event_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for forwarded client_time payload**
|
||||||
|
|
||||||
|
Assert outgoing run request contains:
|
||||||
|
- `forwardedProps.client_time.device_timezone`
|
||||||
|
- `client_now_iso`
|
||||||
|
- `client_epoch_ms`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to confirm RED**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement payload injection in one place**
|
||||||
|
|
||||||
|
Add a single helper to build client time context and attach it to run input requests.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to confirm GREEN**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
|
||||||
|
### Task 6: Remove implicit local-time rendering and render by event timezone
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart`
|
||||||
|
- Modify: `apps/lib/features/messages/ui/widgets/calendar_message_card.dart`
|
||||||
|
- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart`
|
||||||
|
- Test: `apps/test/features/calendar/ui/calendar_time_utils_test.dart`
|
||||||
|
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for timezone-specific rendering**
|
||||||
|
|
||||||
|
Cover cases:
|
||||||
|
- same UTC event shows different local clock time under different `event.timezone`
|
||||||
|
- list/day/week/month are consistent for one event
|
||||||
|
- create sheet sends explicit timezone in payload
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm RED**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement deterministic time conversion utility**
|
||||||
|
|
||||||
|
Implement one utility used by all calendar UI surfaces:
|
||||||
|
- input: UTC datetime + IANA timezone
|
||||||
|
- output: event-local datetime
|
||||||
|
|
||||||
|
Replace direct `.toLocal()` usage in calendar model/view with this utility.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Enforce explicit timezone on create/update payload**
|
||||||
|
|
||||||
|
Create/update must always include `timezone` field from selected event timezone.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run Flutter tests**
|
||||||
|
|
||||||
|
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
|
||||||
|
### Task 7: End-to-end verification matrix and release checklist
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/plans/timezone-e2e-checklist.md`
|
||||||
|
- Test: `backend/tests/integration/test_schedule_items_routes.py`
|
||||||
|
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add reproducible matrix**
|
||||||
|
|
||||||
|
Matrix axes:
|
||||||
|
- device timezone: `America/Los_Angeles`, `Asia/Shanghai`
|
||||||
|
- profile timezone: `Asia/Shanghai`, `Europe/Paris`
|
||||||
|
- explicit event timezone: `Asia/Tokyo`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run backend + frontend verification commands**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
- `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py tests/unit/core/agentscope/test_calendar_tools.py tests/integration/test_schedule_items_routes.py -v`
|
||||||
|
- `cd apps && flutter test test/features/chat/ag_ui_event_test.dart test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||||
|
|
||||||
|
Expected: all PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual scenario check**
|
||||||
|
|
||||||
|
Manual script:
|
||||||
|
1. device timezone set to Los Angeles
|
||||||
|
2. profile timezone set to Shanghai
|
||||||
|
3. ask agent create "明天上午9点开会"
|
||||||
|
4. verify assistant text, tool card, DB UTC value, and calendar detail all align to chosen event timezone semantics
|
||||||
|
|
||||||
|
- [ ] **Step 4: Capture release notes**
|
||||||
|
|
||||||
|
Record:
|
||||||
|
- removed hidden timezone defaults
|
||||||
|
- deterministic precedence
|
||||||
|
- no schema expansion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Plan complete and saved to `docs/superpowers/plans/2026-03-16-calendar-timezone-unification.md`. Ready to execute?
|
||||||
@@ -185,6 +185,44 @@ interface Context {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## forwardedProps.client_time Schema
|
||||||
|
|
||||||
|
`RunAgentInput.forwardedProps` 支持透传客户端时间上下文。日历相关能力必须使用以下结构:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ForwardedProps {
|
||||||
|
client_time?: {
|
||||||
|
device_timezone: string; // IANA 时区,例如 "America/Los_Angeles"
|
||||||
|
client_now_iso: string; // RFC3339 带偏移时间,例如 "2026-03-16T09:12:33-07:00"
|
||||||
|
client_epoch_ms: number; // Unix epoch 毫秒
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 时间来源优先级(固定)
|
||||||
|
|
||||||
|
后端在运行时按以下顺序解析事件时区:
|
||||||
|
|
||||||
|
1. `event_timezone`(工具调用显式传参)
|
||||||
|
2. `forwardedProps.client_time.device_timezone`
|
||||||
|
3. `users.profile.settings.timezone`
|
||||||
|
4. `UTC`
|
||||||
|
|
||||||
|
### 约束
|
||||||
|
|
||||||
|
- `device_timezone` 必须是有效 IANA 时区。
|
||||||
|
- `client_now_iso` 必须是 RFC3339 且包含时区偏移。
|
||||||
|
- `client_epoch_ms` 必须是整数毫秒时间戳。
|
||||||
|
- 业务代码不得使用服务器本地时区作为事件语义时区。
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- `forwardedProps` 是透传字段,不改变 AG-UI 主体协议结构。
|
||||||
|
- 当 `forwardedProps.client_time` 缺失或非法时,运行时回退到 `users.profile.settings.timezone`。
|
||||||
|
- 日历写入必须在最终工具调用中带上 `event_timezone`,不得依赖工具默认值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
||||||
Backend 实现了以下验证规则:
|
Backend 实现了以下验证规则:
|
||||||
@@ -203,6 +241,16 @@ Backend 实现了以下验证规则:
|
|||||||
| binary 不允许使用 data | `binary content data is not allowed` |
|
| binary 不允许使用 data | `binary content data is not allowed` |
|
||||||
| 单条消息最多 3 张附件 | `Too many attachments` |
|
| 单条消息最多 3 张附件 | `Too many attachments` |
|
||||||
|
|
||||||
|
### forwardedProps.client_time Validation
|
||||||
|
|
||||||
|
建议在后端校验层返回以下错误(按业务实现映射到 4xx):
|
||||||
|
|
||||||
|
| Rule | Error Message |
|
||||||
|
|------|---------------|
|
||||||
|
| `device_timezone` 非 IANA 时区 | `invalid client_time.device_timezone` |
|
||||||
|
| `client_now_iso` 无法解析或缺少时区 | `invalid client_time.client_now_iso` |
|
||||||
|
| `client_epoch_ms` 非整数毫秒值 | `invalid client_time.client_epoch_ms` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Request Example
|
## Request Example
|
||||||
@@ -292,6 +340,32 @@ Backend 实现了以下验证规则:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 带 forwardedProps.client_time 的请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"threadId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"runId": "run-004",
|
||||||
|
"state": {},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "msg-001",
|
||||||
|
"role": "user",
|
||||||
|
"content": "帮我明天早上9点创建一个日历"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tools": [],
|
||||||
|
"context": [],
|
||||||
|
"forwardedProps": {
|
||||||
|
"client_time": {
|
||||||
|
"device_timezone": "America/Los_Angeles",
|
||||||
|
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||||
|
"client_epoch_ms": 1773658353000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Response
|
## Response
|
||||||
@@ -454,3 +528,4 @@ interface UiSchemaRenderer {
|
|||||||
- backend 验证通过后,会将 binary url 转换为内部存储路径
|
- backend 验证通过后,会将 binary url 转换为内部存储路径
|
||||||
- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt
|
- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt
|
||||||
- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)
|
- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)
|
||||||
|
- 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Calendar Timezone Policy Protocol
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
- Current: `1.0`
|
||||||
|
- Status: Active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
统一日历事件在 App、Agent、工具、数据库之间的时间语义,消除时区不一致导致的显示和落库偏差。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical Rules
|
||||||
|
|
||||||
|
1. 数据库存储基准为 UTC。
|
||||||
|
2. 事件语义时区使用 IANA 时区字符串(`event_timezone` / `timezone`)。
|
||||||
|
3. 禁止无时区时间(naive datetime)进入日历写入链路。
|
||||||
|
4. 日历写入必须显式确定事件时区,不允许工具层硬编码默认时区。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timezone Resolution Priority
|
||||||
|
|
||||||
|
运行时事件时区解析顺序固定如下:
|
||||||
|
|
||||||
|
1. `event_timezone`(工具调用显式传参)
|
||||||
|
2. `forwardedProps.client_time.device_timezone`
|
||||||
|
3. `users.profile.settings.timezone`
|
||||||
|
4. `UTC`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Write Contract
|
||||||
|
|
||||||
|
### Required fields
|
||||||
|
|
||||||
|
- `start_at`: RFC3339 且必须包含时区偏移
|
||||||
|
- `timezone`: IANA 时区
|
||||||
|
|
||||||
|
### Optional fields
|
||||||
|
|
||||||
|
- `end_at`: RFC3339 且必须包含时区偏移(若提供)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- `timezone` 非法 -> 拒绝请求
|
||||||
|
- `start_at`/`end_at` 无时区 -> 拒绝请求
|
||||||
|
- `end_at < start_at` -> 拒绝请求
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read Contract
|
||||||
|
|
||||||
|
读接口最小语义要求:
|
||||||
|
|
||||||
|
- 返回 UTC 时间字段(`start_at`, `end_at`)
|
||||||
|
- 返回事件时区字段(`timezone`)
|
||||||
|
- 前端展示必须以 `timezone` 作为事件本地时间转换基准,不允许直接按设备本地时区隐式渲染
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
推荐错误码(由后端映射为 4xx):
|
||||||
|
|
||||||
|
- `INVALID_DATETIME_FORMAT`
|
||||||
|
- `NAIVE_DATETIME_FORBIDDEN`
|
||||||
|
- `INVALID_TIMEZONE`
|
||||||
|
- `TIMEZONE_REQUIRED`
|
||||||
|
- `INVALID_TIME_RANGE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DST Rule
|
||||||
|
|
||||||
|
- 夏令时切换期间,时间解释以 IANA 时区数据库为准。
|
||||||
|
- 对于歧义本地时间(例如回拨重复小时),由后端统一按标准库解析策略处理并返回确定 UTC 结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- 本协议不引入新的数据库时间列。
|
||||||
|
- 本协议不改变 `schedule_items` 现有 `TIMESTAMPTZ + timezone` 存储结构。
|
||||||
Reference in New Issue
Block a user