729 lines
55 KiB
Markdown
729 lines
55 KiB
Markdown
---
|
||
title: Calendar & Schedule Feature
|
||
status: completed
|
||
date: 2026-06-23
|
||
origin: docs/brainstorms/2026-06-23-calendar-schedule-requirements.md
|
||
type: feature
|
||
---
|
||
|
||
## Summary
|
||
|
||
在 AgentKit 客户端嵌入行事历,通过混合模式(ReAct 工具调用 + 时间关键词触发的后处理提取)自动捕获对话中的日程,支持完整的手动管理——循环事件、自定义类型、标签、三种视图(日历/卡片/列表),双向同步 Apple Calendar (CalDAV)、Outlook (Graph API) 和 iCal/ICS,配套独立的多渠道提醒子系统。
|
||
|
||
## Problem Frame
|
||
|
||
Agent 对话中频繁产生可执行的时间安排,但这些信息当前无处记录。痛点不在"缺少一个日历应用",而在"Agent 产生的日程与日历之间没有桥梁"。本计划实现这架桥梁:Agent 工具调用 + 后处理提取 + 手动管理 + 外部同步 + 提醒分发,形成完整的日程闭环。
|
||
|
||
**Scope: Deep** — 跨前端 UI、后端 API、Agent 工具、外部同步、提醒子系统五大领域。
|
||
|
||
## Requirements Traceability
|
||
|
||
| Requirement Group | R-IDs | Covered by Units |
|
||
|---|---|---|
|
||
| 事件数据模型 | R1-R5 | U1, U2 |
|
||
| 视图模式 | R6-R9 | U10 (KTD-12: R9 = summary + drawer) |
|
||
| Agent 自动识别 | R10-R15 | U3, U4 (R15 source distinction: U10) |
|
||
| 手动管理 | R16-R19 | U10, U11 |
|
||
| 外部日历同步 | R20-R24 | U6, U7, U8 (SyncManager: U6) |
|
||
| 提醒子系统 | R25-R29 | U5, U12 |
|
||
| 共享与邀请 | R30-R33 | U2, U11 (R30 user search: U2, R33 invited styling: U10) |
|
||
| UI/UX | R34-R37 | U10, U11 |
|
||
|
||
## Key Technical Decisions
|
||
|
||
**KTD-1. Mirror documents subsystem for backend structure.** 日历后端镜像 `src/agentkit/documents/` 的结构——`calendar/db.py` (aiosqlite bare-connection)、`calendar/models.py` (dataclass DTO)、`calendar/service.py` (business logic)、`calendar/tool.py` (agent tool)。这是代码库中最近验证过的模式,与现有架构一致,避免引入 SQLAlchemy engine/session 的复杂性。Rationale: aiosqlite bare-connection 已在 auth 和 documents 子系统中验证,轻量且与异步架构兼容。
|
||
|
||
**KTD-2. asyncio loop for reminder scheduler, not APScheduler.** 提醒调度器跟随 `src/agentkit/server/task_store.py` 的 `start()`/`stop()` + `asyncio.create_task` 循环模式,在 `app.py` lifespan 中启停。Rationale: 不引入新依赖,与现有后台任务模式一致(task_store cleanup loop、session writer loop)。`ponytail: asyncio.sleep 轮询的精度是秒级,如果未来需要亚秒级调度可升级到 APScheduler。` **Note:** 需求文档提到"新增依赖:后台调度器(如 APScheduler)",本决策选择不引入 APScheduler 而复用 asyncio loop 模式——这是有意识的偏离,因为 task_store 已验证此模式可行,且避免新依赖。
|
||
|
||
**KTD-3. python-dateutil.rrule for recurrence expansion.** 使用 `dateutil.rrule` 处理 RRULE 规则展开(RFC 5545 兼容),支持 FREQ/INTERVAL/COUNT/UNTIL/BYDAY。Rationale: 标准库 `datetime` 不支持循环规则,dateutil 是 Python 生态中唯一成熟的 RRULE 实现,且是 icalendar 库的依赖。
|
||
|
||
**KTD-4. caldav library for Apple Calendar, httpx for Outlook Graph API.** Apple Calendar 同步使用 `caldav` 库(CalDAV 协议),Outlook 同步直接用 `httpx` 调用 Microsoft Graph REST API(不引入 msgraph SDK)。Rationale: caldav 库是 Python 生态中唯一成熟的 CalDAV 客户端;msgraph SDK 过重(数百 MB),httpx 已在依赖中,Graph REST API 简单直接。
|
||
|
||
**KTD-5. icalendar library for ICS import/export.** 使用 `icalendar` 库生成和解析 .ics 文件。Rationale: 手写 iCalendar 格式容易出错(编码、转义、时区),icalendar 库是事实标准。
|
||
|
||
**KTD-6. FullCalendar Vue3 for calendar grid views.** 前端日历网格视图(月/周/日)使用 `@fullcalendar/vue3`,样式定制为 Notion Calendar 风格。Rationale: 从零实现带拖拽的月/周/日网格是数千行代码,FullCalendar 是成熟标准库,支持拖拽创建/移动、循环事件展开、视图切换。`ponytail: FullCalendar 是本计划中唯一的大型前端依赖,如果后续需要移除,日历网格组件是独立的可替换层。`
|
||
|
||
**KTD-7. Post-processing hook in chat.py after assistant reply persistence.** 后处理提取钩子插入 `src/agentkit/server/routes/chat.py` 的 `_handle_chat_message()` 中,在 assistant 回复持久化之后(REACT 路径约 line 1158,DIRECT_CHAT 路径约 line 971)触发。先做零 LLM 的正则关键词检测,命中后才调用 LLM 提取。Rationale: 这是对话轮次结束的自然插入点,不改变现有流程,仅在回复完成后追加一个异步后台任务。
|
||
|
||
**KTD-8. aiosmtplib for email reminder channel.** 邮件提醒渠道使用 `aiosmtplib`(异步 SMTP 客户端)。Rationale: smtplib 是同步的会阻塞事件循环,aiosmtplib 是标准异步替代品。
|
||
|
||
**KTD-9. Reuse existing LLM gateway for post-processing extraction.** 后处理提取的 LLM 调用复用 `agentkit.llm` 网关,不新建 LLM 客户端。Rationale: LLM 网关已有 fallback、缓存、用量追踪,直接复用。
|
||
|
||
**KTD-10. WebSocket piggyback for real-time calendar push.** 日历实时更新(Agent 创建事件、提醒触发、邀请通知、同步冲突)复用现有 chat WebSocket 连接,新增 `calendar_event_created`、`calendar_reminder`、`calendar_invitation`、`calendar_sync_conflict` 消息类型。Rationale: 避免新建 WS 连接,前端 chat store 已有消息分发机制。
|
||
|
||
**KTD-11. UTC storage + local display timezone strategy.** 所有时间字段(`start_time`、`end_time`、`scheduled_time`、`last_modified`、`created_at`)以 ISO 8601 UTC 存储在数据库中,前端显示时由浏览器自动转换为本地时区。RRULE 展开基于 UTC 计算,显示时再转本地。Rationale: 统一存储避免时区歧义,浏览器 `Intl.DateTimeFormat` 或 dayjs UTC 插件处理显示转换。`ponytail: 不支持用户手动选择时区——浏览器本地时区即显示时区,跨时区用户需自行调整设备时区。`
|
||
|
||
**KTD-12. Right panel = summary view, drawer = full management.** 右面板日历 tab 仅展示摘要视图(今日 + 未来 3 条事件),点击"展开"按钮打开 80% 宽度的大抽屉,抽屉内包含完整的三视图管理(日历网格/卡片/列表)+ 事件编辑器 + 邀请管理。Rationale: 右面板空间有限,摘要视图满足"快速查看"需求;完整管理功能需要大画布,抽屉模式不离开当前页面上下文。
|
||
|
||
## Architecture
|
||
|
||
### Module Map
|
||
|
||
```
|
||
src/agentkit/calendar/ # New subsystem (mirrors documents/)
|
||
__init__.py
|
||
models.py # @dataclass: CalendarEvent, EventType, Tag, Reminder, ReminderRule, ExternalCalendarConfig, Invitation
|
||
db.py # aiosqlite: init_calendar_db(), CRUD functions
|
||
service.py # CalendarService: business logic, event CRUD, type/tag management
|
||
recurrence.py # RRULE expansion wrapper (dateutil.rrule)
|
||
scheduler.py # ReminderScheduler: start/stop asyncio loop, scan + dispatch
|
||
reminders.py # ReminderDispatcher: client push, email, webhook channels
|
||
extraction.py # PostProcessingExtractor: keyword gate + LLM extraction
|
||
sync/
|
||
__init__.py
|
||
base.py # AbstractSyncProvider interface
|
||
caldav_provider.py # Apple Calendar sync via caldav library
|
||
outlook_provider.py # Outlook sync via httpx + Graph API
|
||
ics_provider.py # ICS import/export via icalendar library
|
||
manager.py # SyncManager: orchestrate providers, conflict resolution
|
||
|
||
src/agentkit/tools/calendar_tool.py # CalendarTool(Tool): create_event, query_events, update_event, delete_event
|
||
|
||
src/agentkit/server/routes/calendar.py # APIRouter(prefix="/calendar"): REST endpoints
|
||
|
||
src/agentkit/server/frontend/src/
|
||
api/calendar.ts # CalendarApiClient extends BaseApiClient
|
||
stores/calendar.ts # useCalendarStore (Pinia Composition API)
|
||
components/calendar/
|
||
CalendarPanel.vue # Right panel tab content (summary view only — KTD-12)
|
||
CalendarDrawer.vue # Large drawer wrapper (full management — KTD-12)
|
||
CalendarGrid.vue # FullCalendar wrapper (month/week/day)
|
||
CardView.vue # Kanban-style card view
|
||
ListView.vue # Sorted list view
|
||
EventBadge.vue # Source distinction badge (manual/agent/post_extract — G3)
|
||
EventEditor.vue # Event create/edit form (a-drawer)
|
||
ReminderConfig.vue # Reminder rule configuration
|
||
SyncSettings.vue # External calendar sync config
|
||
InvitationManager.vue # Invitation response UI
|
||
views/CalendarView.vue # Top-level route page (optional, for drawer mode)
|
||
```
|
||
|
||
### Wiring Points
|
||
|
||
| Integration | File | Location |
|
||
|---|---|---|
|
||
| DB init + service | `src/agentkit/server/app.py` | lifespan startup (~line 263, next to `init_documents_db()`) |
|
||
| Route registration | `src/agentkit/server/app.py` | ~line 954, `app.include_router(calendar.router, prefix="/api/v1")` |
|
||
| Tool registration | `src/agentkit/server/app.py` | ~line 268, `agent._tool_registry.register(CalendarTool(...))` |
|
||
| Scheduler start | `src/agentkit/server/app.py` | lifespan startup (~line 151, next to `task_store.start_cleanup()`) |
|
||
| Scheduler stop | `src/agentkit/server/app.py` | lifespan shutdown (~line 430) |
|
||
| SyncManager start | `src/agentkit/server/app.py` | lifespan startup (next to reminder scheduler) |
|
||
| SyncManager stop | `src/agentkit/server/app.py` | lifespan shutdown (next to reminder scheduler stop) |
|
||
| Post-processing hook | `src/agentkit/server/routes/chat.py` | ~line 1158 (REACT), ~line 971 (DIRECT_CHAT) |
|
||
| WebSocket push | `src/agentkit/server/routes/chat.py` | `chat_manager.send_json(session_id, {...})` |
|
||
| Frontend right panel tab | `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` | `bottomRightTabs` array (~line 153) |
|
||
| Frontend WS handler | `src/agentkit/server/frontend/src/stores/chat.ts` | `handleWsMessage()` switch (~line 586) |
|
||
| Frontend WS types | `src/agentkit/server/frontend/src/api/types.ts` | `WsServerMessage` union (~line 111) |
|
||
|
||
### Data Model
|
||
|
||
```python
|
||
# src/agentkit/calendar/models.py (dataclasses, mirrors documents/models.py)
|
||
|
||
@dataclass
|
||
class EventType:
|
||
id: str # UUID
|
||
user_id: str # FK to users
|
||
name: str # "会议", "截止", "个人"
|
||
color: str # "#4A90D9"
|
||
is_default: bool # system-provided types
|
||
|
||
@dataclass
|
||
class Tag:
|
||
id: str
|
||
user_id: str
|
||
name: str
|
||
|
||
@dataclass
|
||
class CalendarEvent:
|
||
id: str
|
||
user_id: str
|
||
title: str
|
||
description: str
|
||
start_time: str # ISO 8601 UTC (see KTD-11)
|
||
end_time: str # ISO 8601 UTC
|
||
is_all_day: bool
|
||
location: str
|
||
event_type_id: str | None
|
||
rrule: str | None # RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
|
||
source: str # "manual" | "agent" | "post_extract"
|
||
is_invited: bool # True if this event arrived via invitation (R33 — special styling)
|
||
conversation_id: str | None # origin traceability for agent/extracted events
|
||
external_id: str | None # ID in external calendar (for sync)
|
||
external_provider: str | None # "caldav" | "outlook"
|
||
last_modified: str # ISO 8601 UTC, for conflict resolution (last-write-wins)
|
||
created_at: str # ISO 8601 UTC
|
||
|
||
@dataclass
|
||
class EventTag: # many-to-many junction
|
||
event_id: str
|
||
tag_id: str
|
||
|
||
@dataclass
|
||
class ReminderRule:
|
||
id: str
|
||
event_id: str # FK to events (nullable for type-level defaults)
|
||
event_type_id: str # FK to event_types (for default reminders)
|
||
offset_minutes: int # -15 = 15 min before, -1440 = 1 day before
|
||
channels: list[str] # ["client", "email", "webhook"]
|
||
|
||
@dataclass
|
||
class ReminderDelivery:
|
||
id: str
|
||
reminder_rule_id: str
|
||
event_id: str
|
||
scheduled_time: str
|
||
status: str # "pending" | "sent" | "failed" | "read"
|
||
channel: str
|
||
attempts: int
|
||
last_error: str | None
|
||
|
||
@dataclass
|
||
class ExternalCalendarConfig:
|
||
id: str
|
||
user_id: str
|
||
provider: str # "caldav" | "outlook"
|
||
credentials: str # encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token)
|
||
sync_frequency: int # minutes
|
||
sync_scope: list[str] # event type IDs to sync, empty = all
|
||
last_sync: str | None
|
||
sync_token: str | None # delta token for incremental sync
|
||
|
||
@dataclass
|
||
class Invitation:
|
||
id: str
|
||
event_id: str
|
||
inviter_user_id: str
|
||
invitee_email: str
|
||
status: str # "pending" | "accepted" | "declined" | "tentative"
|
||
responded_at: str | None
|
||
```
|
||
|
||
---
|
||
|
||
## Implementation Units
|
||
|
||
### U1. Backend data model & storage
|
||
|
||
- **Goal:** Define calendar dataclasses, aiosqlite DB schema with CRUD functions, and RRULE recurrence expansion wrapper.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/__init__.py` (new)
|
||
- `src/agentkit/calendar/models.py` (new — dataclasses above)
|
||
- `src/agentkit/calendar/db.py` (new — `init_calendar_db()`, `insert_event()`, `get_event()`, `list_events()`, `update_event()`, `delete_event()`, same for types/tags/reminders/configs/invitations)
|
||
- `src/agentkit/calendar/recurrence.py` (new — `expand_rrule(rrule_str, start_time, range_start, range_end) -> list[str]` wrapper around `dateutil.rrule`)
|
||
- **Patterns:** Mirror `src/agentkit/documents/db.py` (aiosqlite bare-connection, `PRAGMA journal_mode=WAL`, module-level async functions). Mirror `src/agentkit/documents/models.py` for dataclass DTO pattern. `recurrence.py` wraps `dateutil.rrule.rrulestr()` + `between()` for date-range expansion.
|
||
- **DB schema:** 7 tables — `calendar_events`, `calendar_event_types`, `calendar_tags`, `calendar_event_tags`, `calendar_reminder_rules`, `calendar_reminder_deliveries`, `calendar_external_configs`, `calendar_invitations`. All with `String(36)` UUIDs, ISO 8601 UTC timestamps (see KTD-11), indexes on `user_id` and `start_time`.
|
||
- **RRULE expansion (G1, G9):** `recurrence.py` provides `expand_rrule(rrule_str, dtstart, range_start, range_end)` returning a list of ISO 8601 UTC occurrence strings within the range. Used by: (a) `CalendarService.list_events()` to expand recurring events for display, (b) `ReminderScheduler` to find occurrences entering reminder window, (c) sync providers to push individual occurrences if external calendar requires expanded events. All times in UTC per KTD-11.
|
||
- **Test file:** `tests/unit/calendar/test_db.py`, `tests/unit/calendar/test_recurrence.py`
|
||
- **Test scenarios:**
|
||
- `test_init_calendar_db_creates_all_tables` — call `init_calendar_db()`, verify tables exist
|
||
- `test_insert_and_get_event_roundtrip` — insert event, fetch by id, all fields preserved (including `is_invited`)
|
||
- `test_list_events_by_user_filtered_by_date_range` — insert 3 events, query range covering 2
|
||
- `test_update_event_modifies_fields` — insert, update title, verify
|
||
- `test_delete_event_removes_record` — insert, delete, verify gone
|
||
- `test_event_type_crud` — create/list/update/delete event types
|
||
- `test_tag_many_to_many` — event with 3 tags, query by tag returns event
|
||
- `test_reminder_rule_crud` — create rule for event, create default rule for type
|
||
- `test_external_config_stores_encrypted_credentials` — insert config, verify credentials field is opaque
|
||
- `test_expand_rrule_weekly_count` — `FREQ=WEEKLY;BYDAY=MO;COUNT=4` from Monday → 4 occurrences
|
||
- `test_expand_rrule_daily_range_filter` — `FREQ=DAILY` starting Jan 1, range Jan 3–Jan 5 → 3 occurrences
|
||
- `test_expand_rrule_until_clause` — `FREQ=DAILY;UNTIL=20260131` → occurrences stop at Jan 31
|
||
- `test_expand_rrule_no_rrule_returns_single` — `rrule=None` → returns `[start_time]` only
|
||
- `test_expand_rrule_all_day_event` — all-day event with RRULE, verify date (not datetime) expansion
|
||
- `test_expand_rrule_dst_boundary` — event crossing DST transition, verify UTC consistency
|
||
- **Dependencies:** None (foundation unit)
|
||
|
||
### U2. Backend calendar service & REST API
|
||
|
||
- **Goal:** Business logic layer + REST endpoints for full calendar CRUD, event types, tags, invitations, and non-admin user search for invitations.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/service.py` (new — `CalendarService` class)
|
||
- `src/agentkit/server/routes/calendar.py` (new — `APIRouter(prefix="/calendar", tags=["calendar"])`)
|
||
- **Patterns:** Mirror `src/agentkit/documents/service.py` (service class with injected dependencies) and `src/agentkit/server/routes/documents.py` (route module with `_verify_api_key` or `require_authenticated` dependency, Pydantic request/response models, `request.app.state.calendar_service` access).
|
||
- **Service methods:** `create_event()`, `get_event()`, `list_events()` (with RRULE expansion via `recurrence.expand_rrule`), `update_event()`, `delete_event()`, `list_event_types()`, `create_event_type()`, `update_event_type()`, `list_tags()`, `create_tag()`, `create_invitation()`, `respond_to_invitation()`, `list_invitations()`, `search_users(q)` (returns `[{username, email}]` for invitation picker — G5/A3).
|
||
- **REST endpoints:**
|
||
- `POST /api/v1/calendar/events` — create event
|
||
- `GET /api/v1/calendar/events?start=...&end=...&type_id=...&tag_id=...` — list with filters (tag_id filter covers G2)
|
||
- `GET /api/v1/calendar/events/{id}` — get single
|
||
- `PATCH /api/v1/calendar/events/{id}` — update
|
||
- `DELETE /api/v1/calendar/events/{id}` — delete
|
||
- `POST /api/v1/calendar/events/{id}/invitations` — invite user by email (creates Invitation + pushes `calendar_invitation` WS to invitee — G6)
|
||
- `POST /api/v1/calendar/invitations/{id}/respond` — accept/decline/tentative
|
||
- `GET /api/v1/calendar/invitations` — list pending invitations for current user
|
||
- `GET /api/v1/calendar/users/search?q=xxx` — non-admin user search by username/email, returns `[{username, email}]` only (no user_id leak) — G5/A3. Auth: any authenticated user.
|
||
- `GET /api/v1/calendar/event-types` — list types
|
||
- `POST /api/v1/calendar/event-types` — create type
|
||
- `PATCH /api/v1/calendar/event-types/{id}` — update type
|
||
- `GET /api/v1/calendar/tags` — list tags
|
||
- `POST /api/v1/calendar/tags` — create tag
|
||
- **User search implementation (G5/A3):** `search_users(q)` queries `users` table (from `src/agentkit/server/auth/models.py` `UserModel`) by `username ILIKE '%q%' OR email ILIKE '%q%'`, returns top 10 results as `[{username, email}]`. Does NOT require admin permission — uses standard authenticated user dependency. Existing `GET /api/v1/admin/users` requires admin and has no username/email search, so this new endpoint is necessary.
|
||
- **Invitation notification (G6):** When `create_invitation()` is called, after persisting the `Invitation` record, push `calendar_invitation` WS message to the invitee's active session(s) via `chat_manager.send_json()`. Invitee frontend shows a notification + updates invitation list.
|
||
- **Wiring:** `app.py` lifespan — `await init_calendar_db()`, `app.state.calendar_service = CalendarService()`, `app.include_router(calendar.router, prefix="/api/v1")`.
|
||
- **Test file:** `tests/unit/calendar/test_service.py`, `tests/unit/calendar/test_routes.py`
|
||
- **Test scenarios:**
|
||
- `test_create_event_with_type_and_tags` — create event with type_id and 2 tags, verify all linked
|
||
- `test_list_events_filters_by_date_range` — 3 events across days, filter returns correct subset
|
||
- `test_list_events_filters_by_type_and_tag` — filter combination (G2)
|
||
- `test_list_events_filters_by_tag_only` — 5 events, 2 with tag X, filter tag_id=X returns only 2 (G2)
|
||
- `test_list_events_expands_recurring` — event with `FREQ=DAILY;COUNT=3`, list range covering 2 days → returns 2 expanded occurrences
|
||
- `test_update_event_partial_fields` — PATCH only title, other fields unchanged
|
||
- `test_delete_event_cascades_reminders_and_tags` — delete event, verify reminder rules and junction rows removed
|
||
- `test_create_invitation_pushes_ws_to_invitee` — create invitation, verify `chat_manager.send_json()` called with `calendar_invitation` type (G6)
|
||
- `test_respond_to_invitation_updates_status` — invite, respond "accepted", verify status + `responded_at` set
|
||
- `test_search_users_by_username` — seed 3 users, search "alice" returns matching user with username+email only (G5)
|
||
- `test_search_users_by_email` — search by email domain returns matches (G5)
|
||
- `test_search_users_no_match_returns_empty` — search "zzz" returns `[]`
|
||
- `test_search_users_requires_auth` — no auth header → 401
|
||
- `test_search_users_returns_max_10` — seed 15 matching users, verify only 10 returned
|
||
- `test_event_type_default_color_persistence` — create type with color, verify roundtrip
|
||
- `test_route_create_event_requires_auth` — no auth header → 401
|
||
- `test_route_list_events_returns_paginated` — large result set with limit/offset
|
||
- **Dependencies:** U1
|
||
|
||
### U3. Agent calendar tool (ReAct integration)
|
||
|
||
- **Goal:** CalendarTool for ReAct loop — Agent autonomously calls `create_event`, `query_events`, `update_event`, `delete_event`.
|
||
- **Files:**
|
||
- `src/agentkit/tools/calendar_tool.py` (new — `CalendarTool(Tool)`)
|
||
- **Patterns:** Mirror `src/agentkit/tools/document_tool.py` — extend `Tool` ABC, define `input_schema` as JSON Schema dict, `execute(**kwargs)` dispatches by `action` parameter, returns `{"success": True/False, ...}` dict, errors caught not raised.
|
||
- **Tool actions:**
|
||
- `create_event` — params: title, start_time, end_time, description, location, event_type_name, tag_names, rrule, is_all_day
|
||
- `query_events` — params: start_date, end_date, limit
|
||
- `update_event` — params: event_id, fields to update
|
||
- `delete_event` — params: event_id
|
||
- **Source marking:** All events created via tool set `source="agent"` and `conversation_id` from execution context.
|
||
- **Wiring:** `app.py` ~line 268 — `agent._tool_registry.register(CalendarTool(service=calendar_service))`.
|
||
- **Test file:** `tests/unit/tools/test_calendar_tool.py`
|
||
- **Test scenarios:**
|
||
- `test_create_event_action_returns_success` — call execute with create_event action, verify event in DB with source="agent"
|
||
- `test_create_event_with_recurrence_sets_rrule` — rrule param stored correctly
|
||
- `test_query_events_returns_list` — create 2 events, query, verify both returned
|
||
- `test_update_event_action_modifies_fields` — create then update title
|
||
- `test_delete_event_action_removes_record` — create then delete
|
||
- `test_invalid_action_returns_error` — unknown action → `{"success": False, "error": "..."}`
|
||
- `test_missing_required_field_returns_error` — create_event without title → error
|
||
- `test_created_event_has_conversation_id` — verify conversation_id is set from context
|
||
- **Dependencies:** U2
|
||
|
||
### U4. Post-processing extraction (keyword gating + LLM)
|
||
|
||
- **Goal:** After conversation turn, detect time keywords (zero-LLM regex), if hit then call LLM to extract schedule info, create events with `source="post_extract"`.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/extraction.py` (new — `PostProcessingExtractor` class)
|
||
- **Patterns:** Keyword regex follows the `_TOOL_CONTEXT_RE` / greeting regex pattern in `src/agentkit/chat/request_preprocessor.py`. LLM call via `agentkit.llm` gateway (reuse existing `LLMGateway.complete()` or `acomplete()`).
|
||
- **Keyword regex:** `明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|reminder|提醒|预约|约定|安排`
|
||
- **LLM extraction prompt:** System prompt instructs LLM to extract events as JSON array `[{title, start_time, end_time, description, event_type}]` from conversation text. Empty array if no events.
|
||
- **Flow:**
|
||
1. Hook fires after assistant reply persisted in `chat.py` (~line 1158 REACT, ~line 971 DIRECT_CHAT)
|
||
2. `asyncio.create_task(extractor.extract(conversation_text, conversation_id, user_id))`
|
||
3. Regex keyword scan — if no match, return immediately (zero LLM cost)
|
||
4. If match, call LLM gateway with extraction prompt
|
||
5. Parse JSON response, create events with `source="post_extract"`
|
||
6. Push `calendar_event_created` WS message to client
|
||
- **Wiring:** `chat.py` — after `sm.append_message()` for assistant reply, call `asyncio.create_task(post_extractor.extract(...))`. Extractor instance on `app.state.post_extractor`.
|
||
- **Test file:** `tests/unit/calendar/test_extraction.py`
|
||
- **Test scenarios:**
|
||
- `test_keyword_regex_matches_chinese_time_words` — "明天下午3点" matches, "继续优化吧" doesn't
|
||
- `test_keyword_regex_matches_english_time_words` — "deadline tomorrow" matches
|
||
- `test_no_keyword_skips_llm_call` — mock LLM gateway, verify `.complete()` never called when no keyword
|
||
- `test_keyword_hit_triggers_llm_extraction` — mock LLM returns `[{title: "会议", start_time: "..."}]`, verify event created with source="post_extract"
|
||
- `test_llm_returns_empty_array_creates_nothing` — LLM returns `[]`, no events created
|
||
- `test_malformed_llm_response_handled_gracefully` — LLM returns invalid JSON, no crash, logged
|
||
- `test_extracted_events_have_conversation_id` — verify conversation_id set
|
||
- `test_extraction_does_not_block_chat_response` — verify extract is async, chat reply returns before extraction completes
|
||
- **Dependencies:** U2, U3 (for source marking pattern)
|
||
|
||
### U5. Reminder subsystem (scheduler + multi-channel dispatch)
|
||
|
||
- **Goal:** Background scheduler scans upcoming events, matches reminder rules, dispatches via client push / email / webhook, tracks delivery status with retry.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/scheduler.py` (new — `ReminderScheduler` class)
|
||
- `src/agentkit/calendar/reminders.py` (new — `ReminderDispatcher` class)
|
||
- **Patterns:** Scheduler follows `src/agentkit/server/task_store.py` `start()`/`stop()` + `asyncio.create_task` loop pattern. Dispatcher uses strategy pattern — one method per channel.
|
||
- **Scheduler loop:**
|
||
1. Every 60 seconds: query events where `start_time + offset_minutes` is within next 60s window
|
||
2. For each match, check if `ReminderDelivery` already exists (idempotency)
|
||
3. If not, create `ReminderDelivery` records (one per channel) and dispatch
|
||
4. Failed deliveries retried up to 3 times with exponential backoff
|
||
- **Dispatcher channels:**
|
||
- `client` — `chat_manager.send_json(session_id, {"type": "calendar_reminder", "data": {...}})`
|
||
- `email` — `aiosmtplib.send()` with SMTP config from `agentkit.yaml`
|
||
- `webhook` — `httpx.post()` to user-configured webhook URL
|
||
- **Default reminders:** When event created, if its `event_type_id` has default `ReminderRule`s, clone them to the event.
|
||
- **Wiring:** `app.py` lifespan startup — `reminder_scheduler = ReminderScheduler(calendar_service, dispatcher); app.state.reminder_scheduler = reminder_scheduler; await reminder_scheduler.start()`. Shutdown — `await reminder_scheduler.stop()`.
|
||
- **Test file:** `tests/unit/calendar/test_scheduler.py`, `tests/unit/calendar/test_reminders.py`
|
||
- **Test scenarios:**
|
||
- `test_scheduler_finds_event_within_reminder_window` — event 10 min away, rule offset -15min, scheduler finds it
|
||
- `test_scheduler_skips_event_outside_window` — event 2 hours away, rule offset -15min, not found
|
||
- `test_idempotent_delivery_no_duplicate` — scheduler runs twice, only one delivery record created
|
||
- `test_client_channel_sends_ws_message` — mock chat_manager, verify send_json called
|
||
- `test_email_channel_sends_smtp` — mock aiosmtplib, verify send called with correct params
|
||
- `test_webhook_channel_posts_to_url` — mock httpx, verify POST called
|
||
- `test_failed_delivery_retries_up_to_3_times` — mock channel to fail, verify 3 attempts
|
||
- `test_default_reminders_inherited_from_event_type` — create event with type that has default rules, verify rules cloned
|
||
- `test_scheduler_start_stop_lifecycle` — start(), verify task running, stop(), verify cancelled
|
||
- **Dependencies:** U1, U2
|
||
|
||
### U6. External sync — CalDAV (Apple Calendar)
|
||
|
||
- **Goal:** Bidirectional sync with Apple Calendar via CalDAV protocol using `caldav` library, plus SyncManager orchestration.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/sync/base.py` (new — `AbstractSyncProvider` interface: `pull_changes()`, `push_changes()`, `test_connection()`)
|
||
- `src/agentkit/calendar/sync/caldav_provider.py` (new — `CalDAVSyncProvider`)
|
||
- `src/agentkit/calendar/sync/manager.py` (new — `SyncManager` class — G8)
|
||
- **Patterns:** Provider interface allows future providers (Google Calendar) without changing SyncManager. CalDAV connection uses `caldav.DAVClient(url)` with user Apple ID + app-specific password.
|
||
- **SyncManager (G8):** `SyncManager` orchestrates all providers. Methods: `sync_all(user_id)` (iterates user's `ExternalCalendarConfig`s, calls provider pull/push), `sync_provider(config_id)` (single provider), `resolve_conflict(local_event, remote_event)` (last-write-wins + WS notification). Called by: (a) scheduled sync (asyncio loop, same pattern as reminder scheduler), (b) manual "Sync Now" button, (c) after local event creation/update (push to external). `SyncManager` is registered on `app.state.sync_manager` and started/stopped in `app.py` lifespan.
|
||
- **Sync flow:**
|
||
1. `pull_changes()` — `calendar.date_search(start, end)` to fetch events in range, match by `external_id` (CalDAV UID), create/update local events
|
||
2. `push_changes()` — for local events modified since `last_sync`, `calendar.save_event(ical_data)` to push to CalDAV
|
||
3. Conflict: `last_modified` comparison, last-write-wins, log conflict + push `calendar_sync_conflict` WS notification to user (G4)
|
||
- **ICS generation:** Use `icalendar` library to convert `CalendarEvent` → iCalendar VEVENT (with RRULE if present).
|
||
- **Config:** `ExternalCalendarConfig` stores CalDAV URL, username, app-specific password (encrypted). User configures via settings UI.
|
||
- **Test file:** `tests/unit/calendar/test_sync_caldav.py`, `tests/unit/calendar/test_sync_manager.py`
|
||
- **Test scenarios:**
|
||
- `test_caldav_provider_pull_creates_local_events` — mock caldav client returning 2 events, verify local events created with external_id set
|
||
- `test_caldav_provider_pull_updates_existing_event` — event already exists (matched by external_id), remote modified, local updated
|
||
- `test_caldav_provider_push_creates_remote_event` — local event with no external_id, push creates remote, external_id stored
|
||
- `test_caldav_provider_push_updates_remote_event` — local event modified, push updates remote
|
||
- `test_caldav_conflict_last_write_wins` — both sides modified, newer last_modified wins, conflict logged
|
||
- `test_caldav_conflict_sends_ws_notification` — conflict detected, verify `chat_manager.send_json()` called with `calendar_sync_conflict` type (G4)
|
||
- `test_caldav_rrule_roundtrip` — event with RRULE synced, verify RRULE preserved in both directions
|
||
- `test_caldav_test_connection_success` — mock returns calendars, test_connection() returns True
|
||
- `test_caldav_test_connection_failure` — mock raises, test_connection() returns False with error message
|
||
- `test_sync_manager_sync_all_iterates_providers` — 2 configs, verify both providers called (G8)
|
||
- `test_sync_manager_sync_provider_updates_last_sync` — sync completes, verify config.last_sync updated (G8)
|
||
- `test_sync_manager_resolve_conflict_notifies_user` — conflict, verify WS push (G8/G4)
|
||
- **Dependencies:** U2, U8 (ICS generation)
|
||
|
||
### U7. External sync — Outlook (Microsoft Graph API)
|
||
|
||
- **Goal:** Bidirectional sync with Outlook Calendar via Microsoft Graph REST API using `httpx`.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/sync/outlook_provider.py` (new — `OutlookSyncProvider`)
|
||
- **Patterns:** Same `AbstractSyncProvider` interface. OAuth2 flow — user authorizes via browser, app stores refresh token, auto-refreshes access token. Graph API endpoints: `GET /me/calendarView/delta` for incremental pull, `POST /me/events` for create, `PATCH /me/events/{id}` for update.
|
||
- **Delta sync:** Use Graph API delta query (`$deltaToken` / `$skipToken`) for incremental changes — first call is full sync, subsequent calls are incremental.
|
||
- **OAuth flow:**
|
||
1. User clicks "Connect Outlook" in settings UI
|
||
2. Redirect to Microsoft login with `Calendars.ReadWrite` scope
|
||
3. Callback receives authorization code, exchanges for access + refresh token
|
||
4. Tokens stored in `ExternalCalendarConfig.credentials` (encrypted JSON)
|
||
5. On token expiry (401), auto-refresh using refresh token
|
||
- **Config:** `ExternalCalendarConfig` stores OAuth client_id, refresh_token, access_token (with expiry). Client ID configured in `agentkit.yaml`.
|
||
- **Test file:** `tests/unit/calendar/test_sync_outlook.py`
|
||
- **Test scenarios:**
|
||
- `test_outlook_provider_pull_delta_initial_sync` — mock httpx returning 3 events, verify local events created
|
||
- `test_outlook_provider_pull_delta_incremental` — mock returning 1 new + 1 updated, verify only those processed
|
||
- `test_outlook_provider_push_creates_remote_event` — local event pushed, remote ID stored
|
||
- `test_outlook_provider_push_updates_remote_event` — local modification pushed
|
||
- `test_outlook_token_refresh_on_401` — mock 401 response, verify refresh token used, request retried
|
||
- `test_outlook_conflict_last_write_wins` — both sides modified, newer wins
|
||
- `test_outlook_rrule_roundtrip` — recurring event synced, pattern preserved
|
||
- `test_outlook_test_connection_success` — mock Graph /me returns user, True
|
||
- **Dependencies:** U2, U8 (ICS for RRULE conversion)
|
||
|
||
### U8. iCal/ICS import/export
|
||
|
||
- **Goal:** Import .ics files to local calendar, export local calendar to .ics file, using `icalendar` library.
|
||
- **Files:**
|
||
- `src/agentkit/calendar/sync/ics_provider.py` (new — `ICSProvider`)
|
||
- **Patterns:** `icalendar.Calendar.from_ical(ics_bytes)` for import, `icalendar.Calendar()` + `add_component()` for export. Map VEVENT fields to `CalendarEvent` dataclass.
|
||
- **Import flow:** Parse ICS → for each VEVENT, create `CalendarEvent` (map SUMMARY→title, DTSTART→start_time, RRULE→rrule, etc.) → skip events with duplicate UID already imported.
|
||
- **Export flow:** Query events in date range → for each, create VEVENT component → assemble into Calendar → serialize to .ics bytes → return as downloadable file.
|
||
- **REST endpoints** (in `routes/calendar.py`):
|
||
- `POST /api/v1/calendar/import-ics` — upload .ics file, parse, create events
|
||
- `GET /api/v1/calendar/export-ics?start=...&end=...` — download .ics file
|
||
- **Test file:** `tests/unit/calendar/test_ics_provider.py`
|
||
- **Test scenarios:**
|
||
- `test_import_simple_ics_creates_event` — single VEVENT ICS, verify event created with correct fields
|
||
- `test_import_recurring_ics_preserves_rrule` — VEVENT with RRULE, verify rrule field set
|
||
- `test_import_all_day_event` — DTSTART is date not datetime, verify is_all_day=True
|
||
- `test_import_skips_duplicate_uid` — import same ICS twice, second import creates no new events
|
||
- `test_export_produces_valid_ics` — create event, export, parse result with icalendar, verify roundtrip
|
||
- `test_export_includes_recurrence` — event with rrule, export, verify RRULE in ICS
|
||
- `test_export_date_range_filter` — 3 events, export range covering 2, verify only 2 in ICS
|
||
- `test_import_malformed_ics_raises_error` — invalid ICS bytes, verify graceful error
|
||
- **Dependencies:** U2
|
||
|
||
### U9. Frontend store, API client & types
|
||
|
||
- **Goal:** Pinia store, API client, and TypeScript types for calendar feature.
|
||
- **Files:**
|
||
- `src/agentkit/server/frontend/src/api/calendar.ts` (new — `CalendarApiClient extends BaseApiClient`, types co-located)
|
||
- `src/agentkit/server/frontend/src/stores/calendar.ts` (new — `useCalendarStore` Composition API)
|
||
- `src/agentkit/server/frontend/src/api/types.ts` (modify — add `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` to `WsServerMessage` union)
|
||
- **Patterns:** API client mirrors `src/agentkit/server/frontend/src/api/documents.ts` (extend `BaseApiClient`, singleton export, types co-located, runtime type guard `isCalendarEvent`). Store mirrors `src/agentkit/server/frontend/src/stores/settings.ts` (Composition API, `ref()` state, `computed()` getters, async actions with try/catch + `isLoading`).
|
||
- **API client methods:** `listEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `listEventTypes()`, `createEventType()`, `listTags()`, `createTag()`, `importIcs()`, `exportIcs()`, `createInvitation()`, `respondToInvitation()`, `listInvitations()`, `searchUsers(q)` (G5/A3), `listExternalConfigs()`, `createExternalConfig()`, `testExternalConnection()`, `syncNow(configId)`.
|
||
- **Store state:** `events`, `eventTypes`, `tags`, `isLoading`, `error`, `selectedEvent`, `viewMode` ('calendar'|'card'|'list'), `dateRange`, `pendingInvitations`, `syncConflicts`.
|
||
- **Store actions:** `loadEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `loadEventTypes()`, `loadTags()`, `setViewMode()`, `loadInvitations()`, `respondToInvitation()`, `searchUsers(q)`, `handleWsEvent()` (dispatch for `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` WS messages).
|
||
- **WS integration:** `stores/chat.ts` `handleWsMessage()` — add `case 'calendar_event_created'`, `case 'calendar_reminder'`, `case 'calendar_invitation'`, `case 'calendar_sync_conflict'` branches that call into calendar store (lazy import pattern to avoid circular deps).
|
||
- **Test file:** `tests/unit/frontend/calendar/store.test.ts` (if frontend test infra exists; otherwise manual verification via `npm run typecheck`)
|
||
- **Test scenarios:**
|
||
- Type check: `npm run typecheck` passes with new types
|
||
- `test_loadEvents_populates_state` — mock API, call loadEvents, verify events ref populated
|
||
- `test_createEvent_adds_to_state` — call createEvent, verify events array updated
|
||
- `test_handleWsEvent_calendar_event_created` — call handleWsEvent with calendar_event_created payload, verify event added to state
|
||
- `test_handleWsEvent_calendar_reminder` — call handleWsEvent with reminder payload, verify notification shown
|
||
- `test_handleWsEvent_calendar_invitation` — call handleWsEvent with invitation payload, verify pendingInvitations updated + notification shown (G6)
|
||
- `test_handleWsEvent_calendar_sync_conflict` — call handleWsEvent with conflict payload, verify syncConflicts updated + alert shown (G4)
|
||
- `test_searchUsers_returns_results` — call searchUsers("ali"), verify results populated (G5)
|
||
- `test_viewMode_switch` — setViewMode('card'), verify state
|
||
- **Dependencies:** U2 (API contract must exist)
|
||
|
||
### U10. Frontend calendar views (3 views + right panel tab + drawer)
|
||
|
||
- **Goal:** Three view modes (calendar grid / card / list), right panel tab integration (summary only), expandable drawer (full management), with event source distinction and invited-event styling.
|
||
- **Files:**
|
||
- `src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue` (new — right panel tab, summary view only per KTD-12)
|
||
- `src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue` (new — large drawer wrapper, full management per KTD-12)
|
||
- `src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue` (new — FullCalendar wrapper)
|
||
- `src/agentkit/server/frontend/src/components/calendar/CardView.vue` (new — kanban card view)
|
||
- `src/agentkit/server/frontend/src/components/calendar/ListView.vue` (new — sorted list view)
|
||
- `src/agentkit/server/frontend/src/components/calendar/EventBadge.vue` (new — source distinction badge/icon component — G3)
|
||
- `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (modify — add `calendar` to `bottomRightTabs`)
|
||
- `src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue` (new — tab content wrapper)
|
||
- `package.json` (modify — add `@fullcalendar/vue3`, `@fullcalendar/daygrid`, `@fullcalendar/timegrid`, `@fullcalendar/interaction` deps)
|
||
- **Patterns:** Right panel tab follows existing `QuadrantPanel` tab pattern (add to `bottomRightTabs`, add named slot). Drawer follows `a-drawer` pattern from `SkillDetail.vue` (but wider, ~80% viewport). FullCalendar initialized in `CalendarGrid.vue` with Vue3 wrapper.
|
||
- **Event source distinction (G3, R15):** Each event displays a visual indicator of its origin:
|
||
- `manual` — no badge (default)
|
||
- `agent` — small robot icon (🤖 or `RobotOutlined`) in event card/grid item
|
||
- `post_extract` — small sparkle icon (✨ or `ThunderboltOutlined`) in event card/grid item
|
||
- `EventBadge.vue` component renders the appropriate icon based on `event.source` field, used in CalendarGrid/CardView/ListView
|
||
- **Invited event styling (G7, R33):** Events with `is_invited=True` render with a distinct visual treatment:
|
||
- Dashed border instead of solid
|
||
- Subtle background tint (e.g., light blue overlay)
|
||
- "受邀" tag displayed next to title
|
||
- Clicking an invited event opens InvitationManager (respond UI) instead of EventEditor
|
||
- **Timezone display (KTD-11):** All times received from API are UTC ISO 8601. Frontend converts to local timezone for display using `dayjs(utcTime).local()` or native `Intl.DateTimeFormat`. No user-facing timezone selector — browser local timezone is used.
|
||
- **CalendarPanel (right panel tab — summary only per KTD-12):**
|
||
- Shows today's events + next 3 upcoming events as compact list
|
||
- Each item shows time + title + source badge (G3)
|
||
- "Expand" button opens `CalendarDrawer`
|
||
- Visual highlight animation when new event created (via WS event trigger)
|
||
- Does NOT include view mode switcher, event editor, or full calendar grid — those live in the drawer
|
||
- **CalendarDrawer (large drawer — full management per KTD-12):**
|
||
- `a-drawer` with `width="80%"` and `placement="right"`
|
||
- View mode switcher (calendar/card/list) at top
|
||
- Contains `CalendarGrid`, `CardView`, or `ListView` based on selected mode
|
||
- "New Event" button opens `EventEditor` (U11)
|
||
- Invitation management accessible via tab/section (U11)
|
||
- **CalendarGrid (FullCalendar):**
|
||
- Month/Week/Day views via FullCalendar
|
||
- Drag-to-create (select time range → open EventEditor)
|
||
- Drag-to-move (drag event to new time → call updateEvent)
|
||
- Events colored by `event_type.color`
|
||
- Recurring events expanded by backend (U2 `list_events` with RRULE expansion) — FullCalendar receives pre-expanded occurrences
|
||
- Invited events (is_invited=True) styled with dashed border class (G7)
|
||
- Styled with Notion Calendar aesthetic: clean borders, ample whitespace, soft colors
|
||
- **CardView:**
|
||
- Kanban-style, grouped by date or event type (toggle)
|
||
- Cards draggable between groups (changes date or type)
|
||
- Each card shows source badge (G3) and invited styling (G7)
|
||
- **ListView:**
|
||
- Sorted by start_time, supports inline edit of title, batch select with checkbox
|
||
- Source column with badge (G3)
|
||
- **Wiring:** `AgentLayout.vue` — add `{ key: 'calendar', label: '日历', icon: CalendarOutlined }` to `bottomRightTabs`, add `<template #calendar><CalendarTab /></template>` slot.
|
||
- **Test file:** Manual verification + `npm run typecheck`
|
||
- **Test scenarios:**
|
||
- Type check passes
|
||
- Right panel shows calendar tab with today's events (summary only — no grid)
|
||
- Each event in panel shows correct source badge (manual=none, agent=robot, post_extract=sparkle) (G3)
|
||
- Clicking "Expand" opens drawer with 80% width
|
||
- Drawer contains view mode switcher + full calendar grid
|
||
- Calendar grid shows month view by default, can switch to week/day
|
||
- Drag-select time range opens event editor
|
||
- Drag event to new time updates event
|
||
- Invited events show dashed border + "受邀" tag in all views (G7)
|
||
- Clicking invited event opens invitation response UI, not editor (G7)
|
||
- Card view groups by date, cards draggable, source badge visible (G3)
|
||
- List view sorted by time, inline edit works, source column visible (G3)
|
||
- New event from Agent triggers highlight animation in panel
|
||
- Times displayed in browser local timezone (KTD-11)
|
||
- **Dependencies:** U9
|
||
|
||
### U11. Frontend event editor & management UI
|
||
|
||
- **Goal:** Event create/edit form, drag-and-drop rescheduling, batch operations, invitation management.
|
||
- **Files:**
|
||
- `src/agentkit/server/frontend/src/components/calendar/EventEditor.vue` (new — `a-drawer` width 560)
|
||
- `src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue` (new — invitation list + respond buttons)
|
||
- **Patterns:** Event editor follows `a-drawer` + form pattern from `SkillDetail.vue`. Form uses Ant Design Vue form components (`a-form`, `a-input`, `a-date-picker`, `a-time-picker`, `a-select`, `a-tag`).
|
||
- **EventEditor fields:**
|
||
- Title (a-input, required)
|
||
- Description (a-textarea)
|
||
- Date range (a-range-picker) + time (a-time-picker, hidden if all-day)
|
||
- All-day toggle (a-switch)
|
||
- Location (a-input)
|
||
- Event type (a-select, colored options)
|
||
- Tags (a-select mode="tags", user can create new)
|
||
- Recurrence (a-select: none/daily/weekly/monthly/custom + custom RRULE builder)
|
||
- Reminders (list of offset + channel selectors, add/remove)
|
||
- **Batch operations:** In ListView, checkbox multi-select → toolbar with "Delete", "Change Type", "Add Tag" buttons.
|
||
- **InvitationManager:** Shows list of invitees with status icons, "Invite" button opens user search dialog (calls `GET /api/v1/calendar/users/search?q=xxx` from U2 — G5/A3), invitee view shows accept/decline/tentative buttons. Also displays incoming invitations (received via `calendar_invitation` WS push from U2 — G6) with response buttons.
|
||
- **Test file:** Manual verification + `npm run typecheck`
|
||
- **Test scenarios:**
|
||
- Type check passes
|
||
- Create event with all fields, verify saved
|
||
- Edit existing event, verify changes persisted
|
||
- Set recurrence weekly, verify RRULE generated
|
||
- Add 2 reminders (15min client + 1day email), verify saved
|
||
- Batch select 3 events, delete, verify removed
|
||
- Batch select, change type, verify all updated
|
||
- Invite user by email, verify invitation created (G5)
|
||
- User search dialog returns results from `/api/v1/calendar/users/search` (G5)
|
||
- Invitation response (accept) updates status
|
||
- Incoming invitation notification displays when `calendar_invitation` WS received (G6)
|
||
- **Dependencies:** U9, U10
|
||
|
||
### U12. Frontend reminder & external sync settings UI
|
||
|
||
- **Goal:** Reminder notification display, external calendar connection settings, sync status.
|
||
- **Files:**
|
||
- `src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue` (new — default reminder rules per event type)
|
||
- `src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue` (new — external calendar config)
|
||
- **Patterns:** Settings panels follow existing settings component patterns. Notification display uses Ant Design Vue `a-notification` or `a-message` component.
|
||
- **ReminderConfig:**
|
||
- Per event type: list of default reminder rules (offset + channels)
|
||
- Add/remove rules, save to backend
|
||
- When new event created with this type, rules auto-inherited
|
||
- **SyncSettings:**
|
||
- List of configured external calendars (provider, status, last_sync)
|
||
- "Add Apple Calendar" → form for CalDAV URL + Apple ID + app-specific password
|
||
- "Add Outlook" → OAuth redirect button
|
||
- Per-provider: sync frequency, sync scope (which event types), "Sync Now" button, "Test Connection" button
|
||
- Conflict notifications displayed as alerts when `calendar_sync_conflict` WS message received (G4) — shows event title, provider, conflict details, and resolution taken (last-write-wins)
|
||
- **Notification display:**
|
||
- `calendar_reminder` WS message → `a-notification.warning()` with event title + time
|
||
- `calendar_invitation` WS message → `a-notification.info()` with inviter name + event title, link to respond (G6)
|
||
- `calendar_sync_conflict` WS message → `a-alert` in SyncSettings panel showing conflict details (G4)
|
||
- Tauri mode: also trigger system notification via Tauri notification API
|
||
- **Test file:** Manual verification + `npm run typecheck`
|
||
- **Test scenarios:**
|
||
- Type check passes
|
||
- Add default reminder rule to "会议" type, verify saved
|
||
- Create event of type "会议", verify reminder inherited
|
||
- Add Apple Calendar config, test connection (mock success)
|
||
- Add Outlook config via OAuth (mock redirect)
|
||
- "Sync Now" triggers sync, status updates
|
||
- Reminder WS message displays notification
|
||
- Invitation WS message displays notification with respond link (G6)
|
||
- Conflict WS message displays alert in SyncSettings (G4)
|
||
- **Dependencies:** U9, U10
|
||
|
||
---
|
||
|
||
## Key Flows
|
||
|
||
- F1. Agent tool-call creates event
|
||
- **Trigger:** Agent identifies schedule in ReAct loop
|
||
- **Actors:** A2 (Agent), A1 (User)
|
||
- **Steps:** Agent calls `CalendarTool.execute(action="create_event", ...)` → service writes to DB with `source="agent"` → WS push `calendar_event_created` to client → right panel highlights new event → Agent tells user "已创建日程"
|
||
- **Covers:** R10, R13, R37
|
||
|
||
- F2. Post-processing extraction (keyword-gated)
|
||
- **Trigger:** Conversation turn ends, assistant reply persisted
|
||
- **Actors:** A2 (post-processing layer), A1 (User)
|
||
- **Steps:** `asyncio.create_task(extractor.extract(...))` → regex keyword scan → if no match, return (zero LLM) → if match, LLM extraction → parse JSON → create events with `source="post_extract"` → WS push → insert "检测到 N 条日程" hint in chat
|
||
- **Covers:** R11, R12, R14
|
||
|
||
- F3. External calendar sync
|
||
- **Trigger:** Scheduled sync task (every N minutes per provider config, run by `SyncManager`) or manual "Sync Now"
|
||
- **Actors:** A3 (external service), A1 (User)
|
||
- **Steps:** `SyncManager.sync(provider)` → `provider.pull_changes()` (fetch remote, match by external_id, create/update local) → `provider.push_changes()` (push local changes to remote) → conflict detection (last_modified comparison) → last-write-wins → `calendar_sync_conflict` WS notification to user (G4)
|
||
- **Covers:** R20, R21, R23
|
||
|
||
- F4. Reminder dispatch
|
||
- **Trigger:** Scheduler loop (every 60s) finds event entering reminder window
|
||
- **Actors:** A4 (reminder subsystem), A1 (User)
|
||
- **Steps:** Query events where `start_time + offset_minutes` in next 60s → check idempotency (delivery exists?) → create `ReminderDelivery` records → dispatch per channel (client WS / email SMTP / webhook HTTP) → update delivery status → retry on failure (up to 3x)
|
||
- **Covers:** R25, R26, R27, R29
|
||
|
||
- F5. Manual event creation
|
||
- **Trigger:** User clicks "New" or drag-selects time range in calendar view
|
||
- **Actors:** A1 (User)
|
||
- **Steps:** Open `EventEditor` drawer → fill form (title/time/type/tags/reminders) → save → `calendarApi.createEvent()` → store updates → event appears in view → if external sync configured, async push to external calendar
|
||
- **Covers:** R16, R18
|
||
|
||
## Dependencies
|
||
|
||
### New Python dependencies (add to `pyproject.toml`)
|
||
|
||
| Package | Version | Used by |
|
||
|---|---|---|
|
||
| `python-dateutil>=2.9` | latest | U1 (RRULE expansion) |
|
||
| `icalendar>=6.0` | latest | U6, U7, U8 (ICS format) |
|
||
| `caldav>=1.3` | latest | U6 (Apple Calendar sync) |
|
||
| `aiosmtplib>=3.0` | latest | U5 (email reminder channel) |
|
||
|
||
### New Node dependencies (add to `package.json`)
|
||
|
||
| Package | Used by |
|
||
|---|---|
|
||
| `@fullcalendar/vue3` | U10 (calendar grid) |
|
||
| `@fullcalendar/daygrid` | U10 (month view) |
|
||
| `@fullcalendar/timegrid` | U10 (week/day view) |
|
||
| `@fullcalendar/interaction` | U10 (drag-and-drop) |
|
||
|
||
### Existing dependencies reused
|
||
|
||
- `httpx>=0.27` — Outlook Graph API calls (U7), webhook reminders (U5)
|
||
- `aiosqlite>=0.20` — local storage (U1)
|
||
- `pydantic>=2.0` — request/response models (U2)
|
||
- `fastapi>=0.110` — REST routes (U2)
|
||
- `redis>=5.0` — optional cache for sync tokens (U6, U7)
|
||
|
||
### Sequencing
|
||
|
||
```
|
||
Phase 1 (Foundation): U1 → U2
|
||
Phase 2 (Agent): U3, U4 (parallel, both depend on U2)
|
||
Phase 3 (Reminders): U5 (depends on U1, U2)
|
||
Phase 4 (External sync): U8 → U6, U7 (U6/U7 parallel, both depend on U8)
|
||
Phase 5 (Frontend base): U9 (depends on U2 API contract)
|
||
Phase 6 (Frontend UI): U10 → U11, U12 (U11/U12 parallel, both depend on U10)
|
||
```
|
||
|
||
Units within the same phase can be developed in parallel. Phases are sequential — each phase's units depend on the prior phase's output.
|
||
|
||
## Risks and Mitigations
|
||
|
||
| Risk | Severity | Mitigation |
|
||
|---|---|---|
|
||
| FullCalendar bundle size impacts frontend load | Medium | Lazy-load calendar components (`defineAsyncComponent`), only load when tab activated |
|
||
| CalDAV iCloud requires app-specific password (user friction) | Medium | Document setup steps in UI, provide "How to get app-specific password" link |
|
||
| Outlook OAuth flow complexity in Tauri desktop mode | Medium | Use system browser for OAuth (not embedded webview), redirect to localhost callback |
|
||
| Post-processing LLM extraction adds latency/cost | Medium | Keyword gate ensures LLM only called when time keywords present; extraction is async, doesn't block chat |
|
||
| RRULE edge cases (DST, timezone, Feb 29) | Low | `dateutil.rrule` handles RFC 5545 edge cases; store all times in UTC, convert at display |
|
||
| Sync conflict data loss | High | Last-write-wins with conflict notification; never auto-delete, always log |
|
||
| Reminder scheduler misses events if server restarts | Medium | On startup, scan for events in reminder window that have no delivery records and dispatch them |
|
||
|
||
## Scope Boundaries
|
||
|
||
### In scope (this plan)
|
||
|
||
- Personal calendar with manual + Agent + post-processing event creation
|
||
- Three view modes (calendar grid, card, list)
|
||
- Apple Calendar (CalDAV), Outlook (Graph API), iCal/ICS bidirectional sync
|
||
- Multi-channel reminder subsystem (client push, email, webhook)
|
||
- Event invitations with accept/decline/tentative
|
||
- Right panel tab + expandable drawer UX
|
||
|
||
### Deferred for later
|
||
|
||
- Google Calendar integration (not selected by user, add CalDAV/Google API provider later)
|
||
- Team shared calendars (multi-user co-editing same calendar)
|
||
- Calendar analytics/reports (event statistics, time distribution)
|
||
- Resource booking (meeting rooms, equipment)
|
||
- Mobile native app (current is Web/Tauri only)
|
||
- Calendar subscription (read-only iCal feed URL for others to subscribe)
|
||
|
||
### Outside this product's identity
|
||
|
||
- Replacing professional calendar applications (Google Calendar, Notion Calendar) — AgentKit calendar's differentiation is Agent auto-detection, not full-featured calendar competition
|
||
- Project management (task tracking, Gantt charts, dependency management)
|
||
|
||
## Implementation History
|
||
|
||
- **U1-U12 实现完成** (commits `2ea799f`..`394d734`): 12 个实现单元全部交付,104 个单元测试通过,ruff clean,frontend typecheck clean。
|
||
- **Intent router 测试修复** (commit `4ea7801`): 修复预存测试失败——关键词匹配平局应保持列表顺序而非字母序。
|
||
- **代码走查修复** (commit `3fdee65`): 使用 ce-code-review skill 多代理审查,发现 23 个问题(2 Critical / 15 Major / 6 Minor),全部修复。关键修复包括:RRULE 无限循环防护、邀请/事件类型授权校验、SQLite 外键 PRAGMA、DTSTART 时区 bug、同步循环 since 重置、CalDAV 超时、Outlook token 过期检测、Webhook SSRF 防护、ICS 导入 DoS 限制。
|