diff --git a/apps/ios/Runner.xcodeproj/project.pbxproj b/apps/ios/Runner.xcodeproj/project.pbxproj index bfe57bf..e8a6c29 100644 --- a/apps/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/ios/Runner.xcodeproj/project.pbxproj @@ -114,7 +114,6 @@ DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */, E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -471,13 +470,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = NHCUQ772U3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp; + PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -653,13 +653,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = NHCUQ772U3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp; + PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -675,13 +676,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = NHCUQ772U3; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp; + PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index 5ddda67..59bb33e 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,38 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + 需要使用相机来拍摄照片 + NSLocalNetworkUsageDescription + 需要访问局域网以连接开发服务器 + NSPhotoLibraryUsageDescription + 需要访问相册来选择照片 + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + NSExceptionDomains + + 192.168.66.57 + + NSTemporaryExceptionAllowsInsecureHTTPLoads + + NSTemporaryExceptionMinimumTLSVersion + TLSv1.0 + + localhost + + NSTemporaryExceptionAllowsInsecureHTTPLoads + + NSTemporaryExceptionMinimumTLSVersion + TLSv1.0 + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,13 +75,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - NSCameraUsageDescription - 需要使用相机来拍摄照片 - NSPhotoLibraryUsageDescription - 需要访问相册来选择照片 diff --git a/apps/lib/core/api/api_exception.dart b/apps/lib/core/api/api_exception.dart index 93a929e..d306560 100644 --- a/apps/lib/core/api/api_exception.dart +++ b/apps/lib/core/api/api_exception.dart @@ -22,7 +22,7 @@ abstract class ApiException implements Exception { (data['detail'] ?? data['message'] ?? data['error'])?.toString() ?? '请求失败'; } else { - detail = '请求失败'; + detail = _networkErrorMessage(error); } final localized = _localizeError(detail, statusCode); @@ -57,6 +57,21 @@ abstract class ApiException implements Exception { } 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 { diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index ebec7b5..d955682 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -94,18 +94,18 @@ class UiSchemaRenderer { final status = _asString(node['status']); final style = switch (role) { 'title' => const TextStyle( - fontSize: 22, + fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.slate900, height: 1.2, ), 'subtitle' => const TextStyle( - fontSize: 16, + fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.slate800, ), 'caption' => const TextStyle( - fontSize: 12, + fontSize: 11, color: AppColors.slate500, height: 1.4, ), @@ -115,9 +115,9 @@ class UiSchemaRenderer { fontFamily: 'monospace', ), _ => const TextStyle( - fontSize: 15, + fontSize: 13, color: AppColors.slate700, - height: 1.45, + height: 1.35, ), }; return Text( @@ -131,7 +131,7 @@ class UiSchemaRenderer { static Widget _renderIcon(Map node) { final value = _asString(node['value']); 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)); } @@ -179,7 +179,7 @@ class UiSchemaRenderer { elevation: 0, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.lg, - vertical: AppSpacing.md, + vertical: AppSpacing.sm, ), backgroundColor: style == 'primary' ? AppColors.authPrimaryButton @@ -196,7 +196,7 @@ class UiSchemaRenderer { ), child: Text( _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, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, - vertical: AppSpacing.sm, + vertical: AppSpacing.xs, ), decoration: BoxDecoration( color: AppColors.surfaceSecondary, @@ -237,7 +237,7 @@ class UiSchemaRenderer { child: Text( label, style: const TextStyle( - fontSize: 12, + fontSize: 11, color: AppColors.slate500, ), ), @@ -248,7 +248,7 @@ class UiSchemaRenderer { child: Text( value, style: const TextStyle( - fontSize: 13, + fontSize: 12, color: AppColors.slate800, fontWeight: FontWeight.w600, ), @@ -290,16 +290,16 @@ class UiSchemaRenderer { }; return Container( width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.xl), + padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: bg, - borderRadius: BorderRadius.circular(AppRadius.xl), + borderRadius: BorderRadius.circular(AppRadius.lg), border: Border.all(color: borderColor), boxShadow: [ BoxShadow( - color: AppColors.slate200.withValues(alpha: 0.6), - blurRadius: 20, - offset: const Offset(0, 10), + color: AppColors.slate200.withValues(alpha: 0.35), + blurRadius: 12, + offset: const Offset(0, 6), ), ], ), diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index f909557..fcb706a 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -41,7 +41,9 @@ const _transcribingStrokeWidth = 2.0; const _attachmentPreviewSize = 88.0; const _attachmentPreviewRadius = 10.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 homeBottomInputStackKey = ValueKey('home_bottom_input_stack'); @@ -92,7 +94,7 @@ class _HomeScreenState extends State late final Future Function(String transcript) _autoSendTranscript; late final AnimationController _listeningAnimationController; bool _isRecording = false; - bool _isHoldToSpeakMode = false; + bool _isHoldToSpeakMode = true; bool _isTranscribing = false; bool _isCancelGestureActive = false; bool _isSendingMessage = false; @@ -356,12 +358,29 @@ class _HomeScreenState extends State if (_isPullRefreshing) { return; } + final chatBloc = context.read(); + if (chatBloc.state.isLoadingHistory) { + return; + } + final hasEarlierHistory = chatBloc.state.hasEarlierHistory; if (mounted) { setState(() => _isPullRefreshing = true); } + final startedAt = DateTime.now(); try { - await context.read().loadMoreHistory(); + if (hasEarlierHistory) { + await chatBloc.loadMoreHistory(); + } else { + Toast.show(context, '没有更早的历史记录了', type: ToastType.info); + } } finally { + final elapsed = DateTime.now().difference(startedAt); + final minDuration = const Duration( + milliseconds: _pullRefreshMinVisibleMs, + ); + if (elapsed < minDuration) { + await Future.delayed(minDuration - elapsed); + } if (mounted) { setState(() => _isPullRefreshing = false); } @@ -585,7 +604,32 @@ class _HomeScreenState extends State } Widget _buildToolResultItem(ToolResultItem item) { - return UiSchemaRenderer.renderSchema(item.uiSchema); + final rootNode = item.uiSchema['root']; + final appearance = rootNode is Map + ? 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) { @@ -733,8 +777,7 @@ class _HomeScreenState extends State } void _onHoldToSpeakStart() { - HapticFeedback.heavyImpact(); - HapticFeedback.vibrate(); + HapticFeedback.selectionClick(); setState(() { _isCancelGestureActive = false; }); @@ -747,7 +790,7 @@ class _HomeScreenState extends State _cancelRecording(showToast: false); return; } - HapticFeedback.mediumImpact(); + HapticFeedback.selectionClick(); _stopRecording(autoSendAfterTranscribe: true); } diff --git a/apps/lib/features/home/ui/widgets/home_background_field.dart b/apps/lib/features/home/ui/widgets/home_background_field.dart index 7e76447..e04ecf7 100644 --- a/apps/lib/features/home/ui/widgets/home_background_field.dart +++ b/apps/lib/features/home/ui/widgets/home_background_field.dart @@ -59,24 +59,26 @@ class _HomeBottomGlow extends StatelessWidget { @override Widget build(BuildContext context) { - return Align( - alignment: Alignment.bottomCenter, - child: IgnorePointer( - child: Container( - key: homeBottomGlowKey, - width: double.infinity, - height: AppSpacing.xxl * 6, - margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), - decoration: BoxDecoration( - color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppSpacing.xxl * 2), - boxShadow: [ - BoxShadow( - color: AppColors.homeBackgroundGlow.withValues(alpha: 0.12), - blurRadius: AppSpacing.xxl * 2, - spreadRadius: AppSpacing.md, - ), - ], + return IgnorePointer( + child: Align( + alignment: Alignment.bottomCenter, + child: Transform.translate( + offset: const Offset(0, AppSpacing.lg), + child: Container( + key: homeBottomGlowKey, + width: AppSpacing.xxl * 12, + height: AppSpacing.xxl * 3, + decoration: BoxDecoration( + color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(AppSpacing.xxl * 2), + boxShadow: [ + BoxShadow( + color: AppColors.homeBackgroundGlow.withValues(alpha: 0.1), + blurRadius: AppSpacing.xxl, + spreadRadius: AppSpacing.sm, + ), + ], + ), ), ), ), diff --git a/apps/lib/features/home/ui/widgets/home_floating_header.dart b/apps/lib/features/home/ui/widgets/home_floating_header.dart index bfaf1a4..12c7135 100644 --- a/apps/lib/features/home/ui/widgets/home_floating_header.dart +++ b/apps/lib/features/home/ui/widgets/home_floating_header.dart @@ -26,9 +26,9 @@ class HomeFloatingHeader extends StatelessWidget { key: homeFloatingHeaderKey, padding: const EdgeInsets.fromLTRB( AppSpacing.lg, - AppSpacing.sm, + AppSpacing.xs, AppSpacing.lg, - AppSpacing.sm, + AppSpacing.xs, ), decoration: const BoxDecoration( color: AppColors.homeToolbarSurface, @@ -93,6 +93,11 @@ class _HeaderIconButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(AppSpacing.xs), + constraints: const BoxConstraints( + minWidth: AppSpacing.xxl + AppSpacing.lg, + minHeight: AppSpacing.xxl + AppSpacing.lg, + ), onPressed: onPressed, icon: Icon(icon, size: AppSpacing.xxl, color: AppColors.slate900), ); @@ -109,6 +114,11 @@ class _MessagesButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(AppSpacing.xs), + constraints: const BoxConstraints( + minWidth: AppSpacing.xxl + AppSpacing.lg, + minHeight: AppSpacing.xxl + AppSpacing.lg, + ), onPressed: onPressed, icon: Stack( clipBehavior: Clip.none, diff --git a/apps/test/features/chat/ui_schema_renderer_test.dart b/apps/test/features/chat/ui_schema_renderer_test.dart index d1270b5..bd63491 100644 --- a/apps/test/features/chat/ui_schema_renderer_test.dart +++ b/apps/test/features/chat/ui_schema_renderer_test.dart @@ -59,6 +59,64 @@ void main() { 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 { await tester.pumpWidget( MaterialApp( diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index 891952a..0cdbd6f 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -11,6 +11,7 @@ from core.agentscope.prompts.agent_prompt import ( ) from core.agentscope.prompts.tool_prompt import build_tools_prompt from schemas.agent.system_agent import AgentType +from schemas.agent.forwarded_props import ClientTimeContext from schemas.user.context import UserContext @@ -102,10 +103,14 @@ def _build_env_section( *, user_context: UserContext, now_utc: datetime, + runtime_client_time: ClientTimeContext | None, extra_context: str | None, ) -> str: settings = _get_attr(user_context, "settings") 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") notification = _get_attr(settings, "notification") 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"], "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"], "system_time_utc": (now_utc or datetime.now(timezone.utc)) .astimezone(timezone.utc) .isoformat(), "system_time_local": _resolve_local_time( 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.", f"- Response language default: ai_language={preferences['ai_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.", ] @@ -190,6 +198,7 @@ def build_system_prompt( agent_type: AgentType, user_context: UserContext, now_utc: datetime, + runtime_client_time: ClientTimeContext | None = None, extra_context: str | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None, ) -> str: @@ -198,6 +207,7 @@ def build_system_prompt( _build_env_section( user_context=user_context, now_utc=now_utc, + runtime_client_time=runtime_client_time, extra_context=extra_context, ), _build_safety_section(), diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 4c7be2d..037fc7b 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -30,6 +30,10 @@ from schemas.agent.runtime_models import ( WorkerAgentOutputLite, 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.user import UserContext from services.litellm.service import LiteLLMService @@ -70,6 +74,7 @@ class AgentScopeRunner: run_input: RunAgentInput, ) -> dict[str, Any]: owner_id = UUID(user_context.id) + runtime_client_time = self._resolve_runtime_client_time(run_input=run_input) async with AsyncSessionLocal() as session: worker_toolkit = self._build_worker_toolkit( @@ -86,6 +91,7 @@ class AgentScopeRunner: user_context=user_context, context_messages=context_messages, stage_config=router_config, + runtime_client_time=runtime_client_time, ) worker_output = await self._execute_worker_step( pipeline=pipeline, @@ -94,6 +100,7 @@ class AgentScopeRunner: router_output=router_output, toolkit=worker_toolkit, stage_config=worker_config, + runtime_client_time=runtime_client_time, ) return { @@ -137,6 +144,7 @@ class AgentScopeRunner: user_context: UserContext, context_messages: list[Msg], stage_config: SystemAgentRuntimeConfig, + runtime_client_time: ClientTimeContext | None, ) -> RouterAgentOutput: await self._emit_step_event( pipeline=pipeline, @@ -149,6 +157,7 @@ class AgentScopeRunner: context_messages=context_messages, run_input=run_input, stage_config=stage_config, + runtime_client_time=runtime_client_time, ) router_output = RouterAgentOutput.model_validate(router_result.payload) await persist_router_message( @@ -177,6 +186,7 @@ class AgentScopeRunner: router_output: RouterAgentOutput, toolkit: Any, stage_config: SystemAgentRuntimeConfig, + runtime_client_time: ClientTimeContext | None, ) -> WorkerAgentOutputLite: worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode) await self._emit_step_event( @@ -193,6 +203,7 @@ class AgentScopeRunner: stage_config=stage_config, worker_output_model=worker_output_model, pipeline=pipeline, + runtime_client_time=runtime_client_time, ) worker_output = worker_output_model.model_validate(worker_result.payload) await self._emit_step_event( @@ -234,12 +245,14 @@ class AgentScopeRunner: context_messages: list[Msg], run_input: RunAgentInput, stage_config: SystemAgentRuntimeConfig, + runtime_client_time: ClientTimeContext | None, ) -> StageExecutionResult: tracking_model = self._build_model(stage_config=stage_config) system_prompt = build_system_prompt( agent_type=AgentType.ROUTER, user_context=user_context, now_utc=datetime.now(timezone.utc), + runtime_client_time=runtime_client_time, tools=None, ) response, payload = await finalize_json_response( @@ -281,6 +294,7 @@ class AgentScopeRunner: stage_config: SystemAgentRuntimeConfig, worker_output_model: type[WorkerAgentOutputLite], pipeline: PipelineLike, + runtime_client_time: ClientTimeContext | None, ) -> StageExecutionResult: worker_input = self._build_worker_input_messages(router_output=router_output) tracking_model = self._build_model(stage_config=stage_config) @@ -298,6 +312,7 @@ class AgentScopeRunner: agent_type=AgentType.WORKER, user_context=user_context, now_utc=datetime.now(timezone.utc), + runtime_client_time=runtime_client_time, tools=None, ), 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 diff --git a/backend/src/core/agentscope/schemas/agui_input.py b/backend/src/core/agentscope/schemas/agui_input.py index 6a4d389..c2871d3 100644 --- a/backend/src/core/agentscope/schemas/agui_input.py +++ b/backend/src/core/agentscope/schemas/agui_input.py @@ -6,6 +6,7 @@ from uuid import UUID from ag_ui.core import RunAgentInput from pydantic import ValidationError +from schemas.agent.forwarded_props import parse_forwarded_props_client_time MAX_RUN_INPUT_BYTES = 256_000 MAX_RUN_ID_LENGTH = 128 @@ -101,6 +102,7 @@ def parse_run_input(payload: dict[str, Any]) -> RunAgentInput: raise ValueError("RunAgentInput.messages exceeds limit") if _user_text_chars(run_input) > MAX_TEXT_CHARS: raise ValueError("RunAgentInput user message text exceeds limit") + parse_forwarded_props_client_time(getattr(run_input, "forwarded_props", None)) return run_input diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index f8040be..60ab571 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -1,4 +1,3 @@ -from datetime import datetime, timedelta, timezone from typing import Annotated, Any, Literal, cast from uuid import UUID @@ -22,7 +21,8 @@ from core.agentscope.tools.utils.calendar_ui import ( calendar_write_hints, 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 ( ScheduleItemCreateRequest, ScheduleItemShareRequest, @@ -146,85 +146,126 @@ async def calendar_read( async def calendar_write( - operation: Annotated[ - 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, + operations: Annotated[ + list[Literal["create", "update", "delete"]], Field( - description="Minutes before start time to trigger reminder (0-10080).", - ge=0, - le=10080, + description=( + "Batch operations list. Each item must be create, update, or delete." + ), + 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, - status: Annotated[ - Literal["active", "completed", "canceled", "archived"] | None, - Field(description="Event status: active, completed, canceled, or archived."), + titles: Annotated[ + list[str | None] | None, + 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, session: Any = None, owner_id: Any = None, ) -> ToolResponse: - """Create, update, or delete a calendar event. + """Batch create/update/delete calendar events using aligned list parameters. Args: - operation: Write operation type, one of create, update, delete. - event_id: Target event id for update and delete operations. - title: Event title. - description: Event description. - start_at: Event start time in ISO 8601 format. - end_at: Event end time in ISO 8601 format. - event_timezone: IANA timezone string. - location: Event location. - color: Event color in hex format, for example #4F46E5. - reminder_minutes: Reminder lead time in minutes. - status: Event status value. + operations: Operation list. Length defines batch size. + event_ids: Optional event id list aligned with operations. + titles: Optional title list aligned with operations. + descriptions: Optional description list aligned with operations. + start_ats: Optional start time list aligned with operations. + end_ats: Optional end time list aligned with operations. + event_timezones: Optional event timezone list aligned with operations. + locations: Optional location list aligned with operations. + colors: Optional color list aligned with operations. + reminder_minutes_list: Optional reminder minute list aligned with operations. + 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: ToolResponse with serialized ToolAgentOutput payload. """ 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 = { - "operation": operation, - "event_id": event_id, - "title": title, - "description": description, - "start_at": start_at, - "end_at": end_at, - "event_timezone": event_timezone, - "location": location, - "color": color, - "reminder_minutes": reminder_minutes, - "status": status, + "operations": operations, + "event_ids": event_ids, + "titles": titles, + "descriptions": descriptions, + "start_ats": start_ats, + "end_ats": end_ats, + "event_timezones": event_timezones, + "locations": locations, + "colors": colors, + "reminder_minutes_list": reminder_minutes_list, + "statuses": statuses, } runtime_error = _validate_runtime_context( tool_name=tool_name, @@ -239,143 +280,235 @@ async def calendar_write( service = create_schedule_service( cast(AsyncSession, session), cast(UUID, owner_id) ) + aligned_event_ids = _align_list("event_ids", event_ids, batch_size) + aligned_titles = _align_list("titles", titles, batch_size) + aligned_descriptions = _align_list("descriptions", descriptions, batch_size) + aligned_start_ats = _align_list("start_ats", start_ats, batch_size) + aligned_end_ats = _align_list("end_ats", end_ats, batch_size) + aligned_event_timezones = _align_list( + "event_timezones", event_timezones, batch_size + ) + 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) - if operation == "create": - parsed_start = parse_iso_datetime(start_at) if start_at else None - if parsed_start is None: - parsed_start = datetime.now(timezone.utc) + timedelta(hours=1) - parsed_end = parse_iso_datetime(end_at) if end_at else None - tz = ( - event_timezone.strip() - if event_timezone and event_timezone.strip() - else "Asia/Shanghai" - ) + success_count = 0 + failed_count = 0 + result_items: list[dict[str, Any]] = [] - created = await service.create_agent_generated( - ScheduleItemCreateRequest( - title=title.strip() if title and title.strip() else "新的日程", - description=description.strip() - if description and description.strip() - else None, - start_at=parsed_start, - end_at=parsed_end, - timezone=tz, - metadata=build_schedule_metadata(location, color, reminder_minutes), - ) - ) - event_dict = schedule_event_to_dict(created) - 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, - ), - ) - ) + 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] - if operation == "update": - if not event_id: - return calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code="INVALID_ARGUMENT", - message="更新日程需要提供 event_id", - retryable=False, - ) - parsed_event_id = UUID(event_id) - update_data: dict[str, Any] = {} - if title: - update_data["title"] = title.strip() - if description: - update_data["description"] = description.strip() - if start_at: - update_data["start_at"] = parse_iso_datetime(start_at) - if end_at: - update_data["end_at"] = parse_iso_datetime(end_at) - if event_timezone: - update_data["timezone"] = event_timezone.strip() - if status: - try: - 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, + 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( + ScheduleItemCreateRequest( + title=title.strip() + if title and title.strip() + else "新的日程", + description=description.strip() + if description and description.strip() + else None, + start_at=parsed_start, + end_at=parsed_end, + timezone=event_timezone.strip(), + metadata=build_schedule_metadata( + location, + color, + cast(int | None, reminder_minutes), + ), + ) ) - if location or color or reminder_minutes is not None: - existing = await service.get_by_id(parsed_event_id) - update_data["metadata"] = merge_schedule_metadata_for_update( - existing_metadata=existing.metadata, - location=location, - color=color, - reminder_minutes=reminder_minutes, + 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 event_id is None or not event_id.strip(): + raise ValueError("更新日程需要提供 event_id") + parsed_event_id = UUID(event_id) + update_data: dict[str, Any] = {} + if title is not None: + update_data["title"] = title.strip() + if description is not None: + update_data["description"] = description.strip() + if start_at: + update_data["start_at"] = parse_iso_datetime(start_at) + if end_at: + update_data["end_at"] = parse_iso_datetime(end_at) + if event_timezone is not None: + timezone_value = event_timezone.strip() + if not timezone_value: + raise ValueError("event_timezone 不能为空") + update_data["timezone"] = timezone_value + if status: + update_data["status"] = ScheduleItemStatus(status) + if location or color or reminder_minutes is not None: + existing = await service.get_by_id(parsed_event_id) + update_data["metadata"] = merge_schedule_metadata_for_update( + existing_metadata=existing.metadata, + location=cast(str | None, location), + color=cast(str | None, color), + reminder_minutes=cast(int | None, reminder_minutes), + ) + updated = await service.update( + parsed_event_id, + ScheduleItemUpdateRequest.model_validate(update_data), + ) + 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 event_id is None or not event_id.strip(): + raise ValueError("删除日程需要提供 event_id") + await service.delete(UUID(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, + } ) - updated = await service.update( - parsed_event_id, ScheduleItemUpdateRequest.model_validate(update_data) + 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, ) - 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, - ), - ) + 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}, ) - if operation == "delete": - if not event_id: - return calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code="INVALID_ARGUMENT", - message="删除日程需要提供 event_id", - retryable=False, - ) - await service.delete(UUID(event_id)) - summary = f"日程 {event_id} 已删除" - 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="delete", - message=summary, - event=None, - event_id=event_id, - ), - ) + 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 calendar_error_output( - tool_name=tool_name, - tool_call_args=tool_call_args, - code="INVALID_ARGUMENT", - message="无效的操作类型", - retryable=False, + return dump_tool_output( + ToolAgentOutput( + tool_name=tool_name, + tool_call_id=f"{tool_name}-call", + tool_call_args=tool_call_args, + status=final_status, + result_summary=summary, + error=error_info, + ui_hints=calendar_write_hints( + operation="batch", + message=summary, + event=None, + event_id=None, + status=ui_status, + ).model_copy( + update={ + "list_items": result_list_items, + "meta": { + "total": batch_size, + "success": success_count, + "failed": failed_count, + }, + } + ), + ) ) except Exception as exc: diff --git a/backend/src/core/agentscope/tools/utils/calendar_domain.py b/backend/src/core/agentscope/tools/utils/calendar_domain.py index 13f8879..04d343a 100644 --- a/backend/src/core/agentscope/tools/utils/calendar_domain.py +++ b/backend/src/core/agentscope/tools/utils/calendar_domain.py @@ -118,11 +118,11 @@ def parse_iso_datetime(value: str | None) -> datetime | None: return None try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - except ValueError: - return None + except ValueError as exc: + raise ValueError("时间格式必须是 ISO8601 且包含时区偏移") from exc + if parsed.tzinfo is None: + raise ValueError("时间必须包含时区信息") + return parsed.astimezone(timezone.utc) def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str]: diff --git a/backend/src/core/agentscope/tools/utils/calendar_ui.py b/backend/src/core/agentscope/tools/utils/calendar_ui.py index 8b5fce2..bbec047 100644 --- a/backend/src/core/agentscope/tools/utils/calendar_ui.py +++ b/backend/src/core/agentscope/tools/utils/calendar_ui.py @@ -79,7 +79,7 @@ def calendar_read_hints( UiHintKvItem(key="page_size", label="每页", value=page_size), UiHintKvItem(key="total_pages", label="总页数", value=total_pages), ], - list_items=event_items, + listItems=event_items, actions=[ UiHintAction( label="打开日历", @@ -97,6 +97,7 @@ def calendar_write_hints( message: str, event: dict[str, Any] | None, event_id: str | None, + status: UiHintStatus = UiHintStatus.SUCCESS, ) -> UiHintsPayload: kv_items: list[UiHintKvItem] = [] @@ -126,10 +127,10 @@ def calendar_write_hints( return UiHintsPayload( intent=UiHintIntent.STATUS, - status=UiHintStatus.SUCCESS, + status=status, title="日历操作完成", body=message, - items=kv_items if kv_items else None, + items=kv_items, actions=[ UiHintAction( label="查看日历", @@ -159,7 +160,5 @@ def calendar_share_hints( UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True), UiHintKvItem(key="permission", label="权限", value=permission_text), ], - list_items=[UiHintListItem(title=email) for email in invited] - if invited - else [], + listItems=[UiHintListItem(title=email) for email in invited] if invited else [], ) diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py index 85e19f4..767febc 100644 --- a/backend/src/schemas/agent/__init__.py +++ b/backend/src/schemas/agent/__init__.py @@ -1,3 +1,7 @@ +from schemas.agent.forwarded_props import ( + ClientTimeContext, + parse_forwarded_props_client_time, +) from schemas.agent.runtime_models import ( ResultType, RouterAgentOutput, @@ -22,6 +26,7 @@ from schemas.agent.ui_hints import ( __all__ = [ "AgentType", + "ClientTimeContext", "ResultType", "RouterAgentOutput", "RouterUiDecision", @@ -39,4 +44,5 @@ __all__ = [ "WorkerAgentOutputRich", "WorkerAgentOutput", "resolve_worker_output_model", + "parse_forwarded_props_client_time", ] diff --git a/backend/src/schemas/agent/forwarded_props.py b/backend/src/schemas/agent/forwarded_props.py new file mode 100644 index 0000000..4afc8a5 --- /dev/null +++ b/backend/src/schemas/agent/forwarded_props.py @@ -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 diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index f080fd7..c4df121 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -3,8 +3,9 @@ from __future__ import annotations from datetime import datetime from typing import ClassVar 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 ( CalendarContent, @@ -49,9 +50,27 @@ class ScheduleItemCreateRequest(BaseModel): description: str | None = Field(default=None, max_length=2000) start_at: datetime 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 + @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): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -64,6 +83,26 @@ class ScheduleItemUpdateRequest(BaseModel): metadata: ScheduleItemMetadata | 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): model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) @@ -99,6 +138,13 @@ class ScheduleItemListRequest(BaseModel): start_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_INVITE = 2 diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index d00492f..0bbd285 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from typing import TYPE_CHECKING, Protocol, Literal from uuid import UUID @@ -83,15 +84,18 @@ class ScheduleItemService(BaseService): ) -> ScheduleItemResponse: 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") data = { "owner_id": user_id, "title": request.title, "description": request.description, - "start_at": request.start_at, - "end_at": request.end_at, + "start_at": normalized_start_at, + "end_at": normalized_end_at, "timezone": request.timezone, "extra_metadata": request.metadata.model_dump() if request.metadata else {}, "source_type": source_type, @@ -168,10 +172,21 @@ class ScheduleItemService(BaseService): # Validate time range next_start = update_data.get("start_at", existing.start_at) next_end = update_data.get("end_at", existing.end_at) - if next_end is not None and next_end <= next_start: - raise HTTPException( - status_code=400, detail="end_at must be after start_at" - ) + 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( + status_code=400, detail="end_at must be after start_at" + ) if not update_data: return self._to_response(existing) @@ -218,13 +233,16 @@ class ScheduleItemService(BaseService): ) -> list[ScheduleItemResponse]: 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") try: subscribed_items = ( 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: 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 diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index c0cc272..f0d32da 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from ag_ui.core import RunAgentInput 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["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" diff --git a/backend/tests/unit/core/agentscope/schemas/test_agui_input.py b/backend/tests/unit/core/agentscope/schemas/test_agui_input.py index 39d74ed..07dd3cf 100644 --- a/backend/tests/unit/core/agentscope/schemas/test_agui_input.py +++ b/backend/tests/unit/core/agentscope/schemas/test_agui_input.py @@ -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.run_id == "run-1" 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) diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index 5647b1d..9d75fe3 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -1,189 +1,166 @@ from __future__ import annotations +import json +from dataclasses import dataclass +from datetime import datetime, timezone from types import SimpleNamespace -from typing import Any, cast +from typing import Any from uuid import uuid4 import pytest -from sqlalchemy.ext.asyncio import AsyncSession - +from agentscope.tool import ToolResponse from core.agentscope.tools.custom import calendar as calendar_module -@pytest.mark.asyncio -async def test_calendar_read_returns_list_payload( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def _fake_execute(**kwargs: Any) -> dict[str, object]: - del kwargs - return {"type": "calendar_event_list.v1", "version": "v1", "data": {"ok": True}} +def _decode_tool_response(response: ToolResponse) -> dict[str, Any]: + assert response.content + first = response.content[0] + if isinstance(first, dict): + text = str(first.get("text", "")) + else: + 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" +@dataclass +class _FakeService: + created_request: Any = None + + async def create_agent_generated(self, request): + self.created_request = request + return SimpleNamespace( + id=uuid4(), + title=request.title, + description=request.description, + start_at=request.start_at, + end_at=request.end_at, + timezone=request.timezone, + metadata=request.metadata, + ) @pytest.mark.asyncio -async def test_calendar_read_requires_valid_user_token( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False) - monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) +async def test_calendar_write_requires_runtime_context() -> None: + result = await calendar_module.calendar_write(operations=["create"]) + payload = _decode_tool_response(result) - result = await calendar_module.calendar_read( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - user_token="bad-token", - ) - - assert result["data"]["ok"] is False - assert result["data"]["code"] == "UNAUTHORIZED" + assert payload["status"] == "failure" + assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS" @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, ) -> None: - captured: dict[str, object] = {} - - 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}} - + fake_service = _FakeService() 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( - session=cast(AsyncSession, SimpleNamespace()), + operations=["create"], + event_timezones=["Asia/Shanghai"], + session=SimpleNamespace(), owner_id=uuid4(), - user_token="token-abc", - operation="update", - event_id=str(uuid4()), - title="新标题", ) - assert result["type"] == "calendar_card.v1" - assert captured["operation"] == "update" - assert "eventId" in captured + payload = _decode_tool_response(result) + + assert payload["status"] == "failure" + assert payload["error"]["code"] == "INVALID_ARGUMENT" + assert "start_at" in payload["error"]["message"] @pytest.mark.asyncio -async def test_calendar_write_maps_reminder_minutes( +async def test_calendar_write_create_requires_event_timezone( monkeypatch: pytest.MonkeyPatch, ) -> None: - captured: dict[str, object] = {} - - 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}} - + fake_service = _FakeService() 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( - session=cast(AsyncSession, SimpleNamespace()), + operations=["create"], + start_ats=["2026-03-16T09:00:00+08:00"], + session=SimpleNamespace(), owner_id=uuid4(), - user_token="token-abc", - operation="update", ) + payload = _decode_tool_response(result) - assert result["type"] == "calendar_operation.v1" - assert result["data"]["ok"] is False - assert result["data"]["code"] == "INVALID_ARGUMENT" + assert payload["status"] == "failure" + assert payload["error"]["code"] == "INVALID_ARGUMENT" + assert "event_timezone" in payload["error"]["message"] @pytest.mark.asyncio -async def test_calendar_share_maps_arguments( +async def test_calendar_write_rejects_naive_start_at( monkeypatch: pytest.MonkeyPatch, ) -> None: - captured: dict[str, object] = {} - - async def _fake_execute(**kwargs: Any) -> dict[str, object]: - 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, + fake_service = _FakeService() + monkeypatch.setattr( + calendar_module, "create_schedule_service", lambda *_: fake_service ) - assert result["type"] == "calendar_operation.v1" - assert captured["eventId"] - assert captured["inviteUserEmails"] == ["a@example.com"] - assert captured["inviteUserNames"] == ["alice"] - assert isinstance(captured["inviteUserIds"], list) - assert captured["invitePermissionView"] is True - assert captured["invitePermissionEdit"] is True - assert captured["invitePermissionInvite"] is True + result = await calendar_module.calendar_write( + operations=["create"], + start_ats=["2026-03-16T09:00:00"], + event_timezones=["Asia/Shanghai"], + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(result) + + assert payload["status"] == "failure" + assert payload["error"]["code"] == "INVALID_ARGUMENT" + assert "时区" in payload["error"]["message"] @pytest.mark.asyncio -async def test_calendar_share_requires_valid_user_token( +async def test_calendar_write_create_normalizes_to_utc( monkeypatch: pytest.MonkeyPatch, ) -> 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_share( - session=cast(AsyncSession, SimpleNamespace()), - owner_id=uuid4(), - user_token="bad-token", - event_id=str(uuid4()), - invite_user_emails=["a@example.com"], + fake_service = _FakeService() + monkeypatch.setattr( + calendar_module, "create_schedule_service", lambda *_: fake_service ) - assert result["data"]["ok"] is False - assert result["data"]["code"] == "UNAUTHORIZED" + result = await calendar_module.calendar_write( + 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"] diff --git a/backend/tests/unit/core/agentscope/test_system_prompt.py b/backend/tests/unit/core/agentscope/test_system_prompt.py index 0dd10c6..a536875 100644 --- a/backend/tests/unit/core/agentscope/test_system_prompt.py +++ b/backend/tests/unit/core/agentscope/test_system_prompt.py @@ -7,6 +7,7 @@ from core.agentscope.prompts.system_prompt import ( _build_env_section, build_system_prompt, ) +from schemas.agent.forwarded_props import ClientTimeContext from schemas.agent.system_agent import AgentType 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( user_context=_build_user_context(), now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), + runtime_client_time=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 "UI labels and short actions default: interface_language=zh-CN." in section 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 ) 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( user_context=_build_user_context(), now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), + runtime_client_time=None, extra_context=None, ) @@ -91,6 +94,7 @@ def test_build_env_section_includes_optional_privacy_and_notification_hints() -> section = _build_env_section( user_context=user_context, now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc), + runtime_client_time=None, 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 +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() -> ( None ): diff --git a/backend/tests/unit/v1/schedule_items/test_schemas.py b/backend/tests/unit/v1/schedule_items/test_schemas.py index 008edeb..dc12914 100644 --- a/backend/tests/unit/v1/schedule_items/test_schemas.py +++ b/backend/tests/unit/v1/schedule_items/test_schemas.py @@ -16,6 +16,7 @@ def test_create_request_valid() -> None: request = ScheduleItemCreateRequest( title="Test Event", start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", ) assert request.title == "Test Event" assert request.timezone == "UTC" @@ -26,6 +27,7 @@ def test_create_request_with_end_at() -> None: title="Test Event", start_at=datetime(2026, 2, 28, 16, 0, 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 @@ -35,6 +37,7 @@ def test_create_request_invalid_title_empty() -> None: ScheduleItemCreateRequest( title="", 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( title="x" * 256, 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( title="Test Event", start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", metadata=metadata, ) assert request.metadata is not None @@ -68,6 +73,24 @@ def test_update_request_partial() -> 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: attachment = ScheduleItemMetadataAttachment( name="document.pdf", @@ -95,7 +118,7 @@ def test_metadata_rejects_invalid_color() -> None: def test_metadata_rejects_invalid_version() -> None: with pytest.raises(ValidationError): - ScheduleItemMetadata(version=2) + ScheduleItemMetadata.model_validate({"version": 2}) def test_metadata_rejects_unknown_field() -> None: diff --git a/backend/tests/unit/v1/schedule_items/test_service.py b/backend/tests/unit/v1/schedule_items/test_service.py index 2ce85a9..2472825 100644 --- a/backend/tests/unit/v1/schedule_items/test_service.py +++ b/backend/tests/unit/v1/schedule_items/test_service.py @@ -148,6 +148,7 @@ async def test_create_success( request = ScheduleItemCreateRequest( title="Test Event", start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", ) service = ScheduleItemService( repository=FakeRepo(None), @@ -171,6 +172,7 @@ async def test_create_invalid_end_at( title="Test Event", start_at=datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc), end_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", ) service = ScheduleItemService( repository=FakeRepo(None), @@ -275,6 +277,7 @@ async def test_create_maps_metadata_to_extra_metadata( request = ScheduleItemCreateRequest( title="Roadmap", start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", metadata=ScheduleItemMetadata( location="会议室A", color="#4F46E5", diff --git a/docs/plans/2026-03-16-calendar-timezone-unification.md b/docs/plans/2026-03-16-calendar-timezone-unification.md new file mode 100644 index 0000000..6c48811 --- /dev/null +++ b/docs/plans/2026-03-16-calendar-timezone-unification.md @@ -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? diff --git a/docs/protocols/agent/run-agent-input.md b/docs/protocols/agent/run-agent-input.md index 5942c6e..3e11201 100644 --- a/docs/protocols/agent/run-agent-input.md +++ b/docs/protocols/agent/run-agent-input.md @@ -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 Backend 实现了以下验证规则: @@ -203,6 +241,16 @@ Backend 实现了以下验证规则: | binary 不允许使用 data | `binary content data is not allowed` | | 单条消息最多 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 @@ -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 @@ -454,3 +528,4 @@ interface UiSchemaRenderer { - backend 验证通过后,会将 binary url 转换为内部存储路径 - `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt - `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase) +- 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区 diff --git a/docs/protocols/calendar/timezone-policy.md b/docs/protocols/calendar/timezone-policy.md new file mode 100644 index 0000000..bba750b --- /dev/null +++ b/docs/protocols/calendar/timezone-policy.md @@ -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` 存储结构。