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
@@ -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 [],
)