diff --git a/src/agentkit/server/frontend/src/api/calendar.ts b/src/agentkit/server/frontend/src/api/calendar.ts new file mode 100644 index 0000000..b6ed927 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/calendar.ts @@ -0,0 +1,324 @@ +/** Calendar API client — thin wrapper over /api/v1/calendar endpoints. */ + +import { BaseApiClient, getDynamicBaseURL } from './base' + +// ── Domain types (co-located with API client) ────────────────────────── + +export interface ICalendarEvent { + id: string + user_id: string + title: string + description: string + start_time: string // ISO 8601 UTC (KTD-11) + end_time: string // ISO 8601 UTC + is_all_day: boolean + location: string + event_type_id: string | null + rrule: string | null // RFC 5545 RRULE string + source: 'manual' | 'agent' | 'post_extract' + is_invited: boolean + conversation_id: string | null + external_id: string | null + external_provider: string | null + last_modified: string + created_at: string +} + +export interface IEventType { + id: string + user_id: string + name: string + color: string + is_default: boolean +} + +export interface ITag { + id: string + user_id: string + name: string +} + +export interface IInvitation { + id: string + event_id: string + inviter_user_id: string + invitee_email: string + status: 'pending' | 'accepted' | 'declined' | 'tentative' + responded_at: string | null +} + +export interface IExternalCalendarConfig { + id: string + user_id: string + provider: 'caldav' | 'outlook' + credentials: string // '***' on read-back; never real credentials + sync_frequency: number + sync_scope: string[] + last_sync: string | null + sync_token: string | null +} + +export interface IUserSearchResult { + username: string + email: string +} + +// ── Request types ────────────────────────────────────────────────────── + +export interface ICreateEventRequest { + title: string + start_time: string + end_time: string + description?: string + location?: string + is_all_day?: boolean + event_type_id?: string | null + rrule?: string | null + tag_ids?: string[] +} + +export interface IUpdateEventRequest { + title?: string + start_time?: string + end_time?: string + description?: string + location?: string + is_all_day?: boolean + event_type_id?: string | null + rrule?: string | null +} + +export interface ICreateEventTypeRequest { + name: string + color?: string +} + +export interface ICreateTagRequest { + name: string +} + +export interface ICreateInvitationRequest { + event_id: string + invitee_email: string +} + +export interface ICreateExternalConfigRequest { + provider: 'caldav' | 'outlook' + credentials: string // JSON string with provider-specific auth + sync_frequency?: number + sync_scope?: string[] +} + +// ── Runtime type guard ───────────────────────────────────────────────── + +/** + * Runtime guard for ICalendarEvent — validates the minimum fields required + * for the calendar store to function safely. + * ponytail: checks only the keys the store actually reads; full schema + * validation belongs at the API boundary, not in the WS event handler. + */ +export function isCalendarEvent(value: unknown): value is ICalendarEvent { + if (typeof value !== 'object' || value === null) return false + const v = value as Record + return ( + typeof v.id === 'string' && + typeof v.title === 'string' && + typeof v.start_time === 'string' && + typeof v.end_time === 'string' && + typeof v.is_all_day === 'boolean' + ) +} + +// ── API client ───────────────────────────────────────────────────────── + +const API_BASE = '/api/v1/calendar' + +class CalendarApiClient extends BaseApiClient { + constructor(baseUrl: string = API_BASE) { + super(baseUrl) + } + + /** List events with optional date/type/tag filters */ + async listEvents( + start?: string, + end?: string, + eventTypeId?: string, + tagId?: string, + ): Promise<{ success: boolean; events: ICalendarEvent[]; count: number }> { + const params = new URLSearchParams() + if (start) params.set('start', start) + if (end) params.set('end', end) + if (eventTypeId) params.set('type_id', eventTypeId) + if (tagId) params.set('tag_id', tagId) + const qs = params.toString() + const path = qs ? `/events?${qs}` : '/events' + return this.request(path, { method: 'GET' }) + } + + /** Create a new event */ + async createEvent( + data: ICreateEventRequest, + ): Promise<{ success: boolean; event: ICalendarEvent }> { + return this.request('/events', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Get a single event by id */ + async getEvent(id: string): Promise<{ success: boolean; event: ICalendarEvent }> { + return this.request(`/events/${id}`, { method: 'GET' }) + } + + /** Update specific fields of an event (PATCH — partial update) */ + async updateEvent( + id: string, + data: IUpdateEventRequest, + ): Promise<{ success: boolean; event: ICalendarEvent; updated: boolean }> { + return this.request(`/events/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + } + + /** Delete an event */ + async deleteEvent(id: string): Promise<{ success: boolean; deleted: boolean }> { + return this.request(`/events/${id}`, { method: 'DELETE' }) + } + + /** List event types for the current user */ + async listEventTypes(): Promise<{ + success: boolean + event_types: IEventType[] + count: number + }> { + return this.request('/event-types', { method: 'GET' }) + } + + /** Create an event type */ + async createEventType( + data: ICreateEventTypeRequest, + ): Promise<{ success: boolean; event_type: IEventType }> { + return this.request('/event-types', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** List tags for the current user */ + async listTags(): Promise<{ success: boolean; tags: ITag[]; count: number }> { + return this.request('/tags', { method: 'GET' }) + } + + /** Create a tag */ + async createTag(data: ICreateTagRequest): Promise<{ success: boolean; tag: ITag }> { + return this.request('/tags', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Import events from an uploaded .ics file (multipart) */ + async importIcs(file: File): Promise<{ + success: boolean + imported: number + skipped: number + [key: string]: unknown + }> { + const formData = new FormData() + formData.append('file', file) + return this.request('/import-ics', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }) + } + + /** + * Build the export-ics download URL for a date range. + * Returns an absolute or relative URL the caller can open/download. + * ponytail: the endpoint returns binary text/calendar, not JSON, so we + * hand back a URL rather than going through request (which JSON-parses). + */ + exportIcs(start?: string, end?: string): string { + const base = getDynamicBaseURL() + const params = new URLSearchParams() + if (start) params.set('start', start) + if (end) params.set('end', end) + const qs = params.toString() + const path = qs ? `/api/v1/calendar/export-ics?${qs}` : '/api/v1/calendar/export-ics' + return base ? `${base}${path}` : path + } + + /** Create an invitation (invite a user to an event by email) */ + async createInvitation( + data: ICreateInvitationRequest, + ): Promise<{ success: boolean; invitation: IInvitation }> { + return this.request(`/events/${data.event_id}/invitations`, { + method: 'POST', + body: JSON.stringify({ invitee_email: data.invitee_email }), + }) + } + + /** List invitations for the current user */ + async listInvitations(): Promise<{ + success: boolean + invitations: IInvitation[] + count: number + }> { + return this.request('/invitations', { method: 'GET' }) + } + + /** Respond to an invitation (accept/decline/tentative) */ + async respondToInvitation( + id: string, + status: 'accepted' | 'declined' | 'tentative', + ): Promise<{ success: boolean; status: string }> { + return this.request(`/invitations/${id}/respond`, { + method: 'POST', + body: JSON.stringify({ status }), + }) + } + + /** Search users by username or email — returns top 10 matches (G5/A3) */ + async searchUsers(q: string): Promise<{ + success: boolean + users: IUserSearchResult[] + count: number + }> { + return this.request(`/users/search?q=${encodeURIComponent(q)}`, { method: 'GET' }) + } + + /** List external calendar configs for the current user */ + async listExternalConfigs(): Promise<{ + success: boolean + configs: IExternalCalendarConfig[] + count: number + }> { + return this.request('/external-configs', { method: 'GET' }) + } + + /** Create an external calendar config */ + async createExternalConfig( + data: ICreateExternalConfigRequest, + ): Promise<{ success: boolean; config: IExternalCalendarConfig }> { + return this.request('/external-configs', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Test connection to an external calendar */ + async testExternalConnection( + id: string, + ): Promise<{ success: boolean; connected: boolean; error?: string }> { + return this.request(`/external-configs/${id}/test`, { method: 'POST' }) + } + + /** Trigger an immediate sync for an external calendar config */ + async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> { + return this.request(`/external-configs/${id}/sync`, { method: 'POST' }) + } +} + +export const calendarApi = new CalendarApiClient() diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts index a6f6f26..4330b27 100644 --- a/src/agentkit/server/frontend/src/api/types.ts +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -1,3 +1,5 @@ +import type { ICalendarEvent, IInvitation } from './calendar' + /** Chat request payload */ export interface IChatRequest { message: string @@ -130,6 +132,11 @@ export type WsServerMessage = | { type: 'round_summary'; data: IRoundSummaryData } | { type: 'user_intervention'; data: IUserInterventionData } | { type: 'board_concluded'; data: IBoardConcludedData } + // Calendar 事件 (KTD-10 — piggyback on chat WS) + | { type: 'calendar_event_created'; data: ICalendarEventCreatedData } + | { type: 'calendar_reminder'; data: ICalendarReminderData } + | { type: 'calendar_invitation'; data: ICalendarInvitationData } + | { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData } /** Expert info within a team */ export interface IExpertInfo { @@ -233,6 +240,39 @@ export interface IBoardMessage { timestamp: number } +// ── Calendar WS 事件 payload 类型 ─────────────────────────────────── + +/** calendar_event_created payload */ +export interface ICalendarEventCreatedData { + event: ICalendarEvent +} + +/** calendar_reminder payload */ +export interface ICalendarReminderData { + event_id: string + title: string + start_time: string + offset_minutes: number + channels: string[] +} + +/** calendar_invitation payload (G6) */ +export interface ICalendarInvitationData { + invitation: IInvitation + event_title: string + inviter_name: string +} + +/** calendar_sync_conflict payload (G4) */ +export interface ICalendarSyncConflictData { + event_id: string + event_title: string + provider: string + local_modified: string + remote_modified: string + resolution: string +} + /** Expert template (matches backend GET /api/v1/experts response item) */ export interface IExpertTemplate { name: string diff --git a/src/agentkit/server/frontend/src/stores/calendar.ts b/src/agentkit/server/frontend/src/stores/calendar.ts new file mode 100644 index 0000000..3d9dcf1 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/calendar.ts @@ -0,0 +1,264 @@ +/** + * Pinia store for calendar feature — events, event types, tags, + * invitations, and WebSocket event dispatch. + * + * ponytail: stores/chat.ts handleWsMessage dispatch is deferred — the + * orchestrator will wire the 4 calendar_* WS cases to call handleWsEvent(). + * This store owns the calendar-specific state mutations + notifications. + */ + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { notification } from 'ant-design-vue' +import { calendarApi, isCalendarEvent } from '@/api/calendar' +import type { + ICalendarEvent, + IEventType, + ITag, + IInvitation, + ICreateEventRequest, + IUpdateEventRequest, + IUserSearchResult, +} from '@/api/calendar' +import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types' + +export type CalendarViewMode = 'calendar' | 'card' | 'list' + +export const useCalendarStore = defineStore('calendar', () => { + // --- State --- + const events = ref([]) + const eventTypes = ref([]) + const tags = ref([]) + const isLoading = ref(false) + const error = ref(null) + const selectedEvent = ref(null) + const viewMode = ref('calendar') + const dateRange = ref<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }) + const pendingInvitations = ref([]) + const syncConflicts = ref([]) + + // --- Getters --- + const upcomingEvents = computed(() => { + const now = new Date().toISOString() + return events.value + .filter((e) => e.start_time >= now) + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + }) + + // --- Actions --- + + /** Load events with current dateRange filter */ + async function loadEvents(): Promise { + isLoading.value = true + error.value = null + try { + const resp = await calendarApi.listEvents( + dateRange.value.start ?? undefined, + dateRange.value.end ?? undefined, + ) + events.value = resp.events || [] + } catch (err) { + error.value = err instanceof Error ? err.message : '加载日程失败' + console.warn('Failed to load calendar events:', err) + } finally { + isLoading.value = false + } + } + + /** Create a new event and add it to state */ + async function createEvent(data: ICreateEventRequest): Promise { + isLoading.value = true + error.value = null + try { + const resp = await calendarApi.createEvent(data) + if (resp.event) { + events.value.push(resp.event) + } + return resp.event ?? null + } catch (err) { + error.value = err instanceof Error ? err.message : '创建日程失败' + console.error('Failed to create event:', err) + throw err + } finally { + isLoading.value = false + } + } + + /** Update an existing event in state */ + async function updateEvent(id: string, data: IUpdateEventRequest): Promise { + error.value = null + try { + const resp = await calendarApi.updateEvent(id, data) + if (resp.event) { + const idx = events.value.findIndex((e) => e.id === id) + if (idx !== -1) { + events.value[idx] = resp.event + } + } + } catch (err) { + error.value = err instanceof Error ? err.message : '更新日程失败' + console.error('Failed to update event:', err) + throw err + } + } + + /** Delete an event from server and state */ + async function deleteEvent(id: string): Promise { + error.value = null + try { + await calendarApi.deleteEvent(id) + events.value = events.value.filter((e) => e.id !== id) + } catch (err) { + error.value = err instanceof Error ? err.message : '删除日程失败' + console.error('Failed to delete event:', err) + throw err + } + } + + /** Load event types for the current user */ + async function loadEventTypes(): Promise { + try { + const resp = await calendarApi.listEventTypes() + eventTypes.value = resp.event_types || [] + } catch (err) { + console.warn('Failed to load event types:', err) + } + } + + /** Load tags for the current user */ + async function loadTags(): Promise { + try { + const resp = await calendarApi.listTags() + tags.value = resp.tags || [] + } catch (err) { + console.warn('Failed to load tags:', err) + } + } + + /** Set the calendar view mode */ + function setViewMode(mode: CalendarViewMode): void { + viewMode.value = mode + } + + /** Load pending invitations for the current user */ + async function loadInvitations(): Promise { + try { + const resp = await calendarApi.listInvitations() + pendingInvitations.value = (resp.invitations || []).filter( + (i) => i.status === 'pending', + ) + } catch (err) { + console.warn('Failed to load invitations:', err) + } + } + + /** Respond to an invitation and remove it from pending list */ + async function respondToInvitation( + id: string, + status: 'accepted' | 'declined' | 'tentative', + ): Promise { + try { + await calendarApi.respondToInvitation(id, status) + pendingInvitations.value = pendingInvitations.value.filter((i) => i.id !== id) + } catch (err) { + error.value = err instanceof Error ? err.message : '回复邀请失败' + console.error('Failed to respond to invitation:', err) + throw err + } + } + + /** Search users by username or email (G5/A3) */ + async function searchUsers(q: string): Promise { + if (!q.trim()) return [] + try { + const resp = await calendarApi.searchUsers(q) + return resp.users || [] + } catch (err) { + console.warn('Failed to search users:', err) + return [] + } + } + + /** + * Dispatch calendar WS messages to the appropriate handler. + * Handles 4 message types: calendar_event_created, calendar_reminder, + * calendar_invitation (G6), calendar_sync_conflict (G4). + * Non-calendar message types are silently ignored. + */ + function handleWsEvent(msg: WsServerMessage): void { + switch (msg.type) { + case 'calendar_event_created': { + const event = msg.data.event + if (isCalendarEvent(event)) { + // Avoid duplicates if the event was created locally + if (!events.value.some((e) => e.id === event.id)) { + events.value.push(event) + } + } + break + } + case 'calendar_reminder': { + const d = msg.data + notification.warning({ + message: '日程提醒', + description: `${d.title} · ${d.start_time}`, + duration: 0, + }) + break + } + case 'calendar_invitation': { + const d = msg.data + if (d.invitation) { + pendingInvitations.value.push(d.invitation) + } + notification.info({ + message: '收到日程邀请', + description: `${d.inviter_name} 邀请你参加「${d.event_title}」`, + duration: 0, + }) + break + } + case 'calendar_sync_conflict': { + const d = msg.data + syncConflicts.value.push(d) + notification.warning({ + message: '日历同步冲突', + description: `「${d.event_title}」与 ${d.provider} 同步冲突,已按 ${d.resolution} 策略处理`, + duration: 0, + }) + break + } + } + } + + return { + // State + events, + eventTypes, + tags, + isLoading, + error, + selectedEvent, + viewMode, + dateRange, + pendingInvitations, + syncConflicts, + // Getters + upcomingEvents, + // Actions + loadEvents, + createEvent, + updateEvent, + deleteEvent, + loadEventTypes, + loadTags, + setViewMode, + loadInvitations, + respondToInvitation, + searchUsers, + handleWsEvent, + } +})