feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user