feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验

This commit is contained in:
zl-q
2026-03-17 00:13:41 +08:00
parent d3783522e6
commit c26cdbbc27
27 changed files with 1532 additions and 412 deletions
@@ -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 [],
)