fischer-agentkit/docs/plans/2026-06-23-003-feat-calenda...

729 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 过重(数百 MBhttpx 已在依赖中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 1158DIRECT_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 3Jan 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 cleanfrontend 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 限制。