From e24db395b8a96b6646b25430b33c06f243303bac Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 9 Mar 2026 21:15:47 +0530 Subject: [PATCH] feat: integrate Notion skill support into Conversations and SkillProvider - Added Notion context building functionality to Conversations component, enhancing user messages with relevant Notion workspace information. - Implemented synchronization of Notion pages and summaries in SkillProvider, ensuring state consistency with the Notion skill. - Expanded Notion slice to include new state properties for pages and summaries, along with corresponding actions for state management. - Enhanced type definitions for Notion-related data structures to improve type safety and clarity. --- src/pages/Conversations.tsx | 63 +++++++++++++++++++++++++++++++++ src/providers/SkillProvider.tsx | 37 +++++++++++++++++-- src/store/notionSlice.ts | 55 ++++++++++++++++++++++++++-- src/store/threadSlice.ts | 12 +++++-- 4 files changed, 160 insertions(+), 7 deletions(-) diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 54cf0e8df..656900e26 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -19,6 +19,7 @@ import { type Tool, } from '../services/api/inferenceApi'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import type { NotionPageSummary, NotionSummary, NotionUserProfile } from '../store/notionSlice'; import { addInferenceResponse, addOptimisticMessage, @@ -38,6 +39,51 @@ import { const MIN_PANEL_WIDTH = 200; const MAX_PANEL_WIDTH = 480; +// --------------------------------------------------------------------------- +// Notion context builder +// --------------------------------------------------------------------------- + +function buildNotionContext( + profile: NotionUserProfile | null, + pages: NotionPageSummary[], + summaries: NotionSummary[], + workspaceName: string | null +): string | null { + if (!profile && pages.length === 0) return null; + + const lines: string[] = ['[NOTION_CONTEXT]']; + + if (workspaceName) lines.push(`Workspace: ${workspaceName}`); + if (profile) { + const who = [profile.name, profile.email].filter(Boolean).join(' ยท '); + if (who) lines.push(`Connected as: ${who}`); + } + + if (pages.length > 0) { + lines.push(`\nRecent Pages (${pages.length} total):`); + const top = pages.slice(0, 10); + for (const p of top) { + const urlPart = p.url ? ` โ€” ${p.url}` : ''; + lines.push(`โ€ข ${p.title}${urlPart}`); + } + } + + if (summaries.length > 0) { + lines.push('\nAI Page Summaries:'); + const top = summaries.slice(0, 5); + for (const s of top) { + const meta = [s.category, s.sentiment !== 'neutral' ? s.sentiment : null] + .filter(Boolean) + .join(', '); + const topicStr = s.topics.length > 0 ? ` | Topics: ${s.topics.slice(0, 4).join(', ')}` : ''; + lines.push(`โ€ข ${s.summary}${meta ? ` [${meta}]` : ''}${topicStr}`); + } + } + + lines.push('[/NOTION_CONTEXT]'); + return lines.join('\n'); +} + function formatRelativeTime(dateStr: string): string { const now = Date.now(); const then = new Date(dateStr).getTime(); @@ -70,6 +116,12 @@ const Conversations = () => { } = useAppSelector(state => state.thread); const skillsState = useAppSelector(state => state.skills); + const notionProfile = useAppSelector(state => state.notion.profile); + const notionPages = useAppSelector(state => state.notion.pages); + const notionSummaries = useAppSelector(state => state.notion.summaries); + const notionWorkspaceName = useAppSelector( + state => (state.skills.skillStates?.notion as Record | undefined)?.workspaceName as string | null ?? null + ); const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); const [confirmDeleteId, setConfirmDeleteId] = useState(null); @@ -282,6 +334,17 @@ const Conversations = () => { // Continue with original message } + // Prepend Notion workspace context if connected + const notionContext = buildNotionContext( + notionProfile, + notionPages, + notionSummaries, + notionWorkspaceName + ); + if (notionContext) { + processedUserContent = `${notionContext}\n\n${processedUserContent}`; + } + const chatMessages: ChatMessage[] = [ ...historySnapshot.map(m => ({ role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant', diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index aea64b40e..c104c2e08 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -23,7 +23,14 @@ import { setGmailProfile, } from '../store/gmailSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { type NotionUserProfile, setNotionProfile } from '../store/notionSlice'; +import { + type NotionPageSummary, + type NotionSummary, + type NotionUserProfile, + setNotionPages, + setNotionProfile, + setNotionSummaries, +} from '../store/notionSlice'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -99,6 +106,20 @@ function syncGmailStateToSlice( syncGmailMetadataToBackend(gmailState as GmailStateForSync); } +/** Sync pages and summaries from notion skill state into notionSlice. */ +function syncNotionStateToSlice( + notionState: Record | undefined, + dispatch: ReturnType +): void { + if (!notionState || typeof notionState !== 'object') return; + if (Array.isArray(notionState.pages)) { + dispatch(setNotionPages(notionState.pages as NotionPageSummary[])); + } + if (Array.isArray(notionState.summaries)) { + dispatch(setNotionSummaries(notionState.summaries as NotionSummary[])); + } +} + async function syncNotionUserOnConnect(dispatch: ReturnType): Promise { try { const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' }); @@ -137,9 +158,15 @@ export default function SkillProvider({ children }: { children: ReactNode }) { syncGmailStateToSlice(gmailSkillState, dispatch); }, [gmailSkillState, dispatch]); + // Keep notionSlice pages in sync with skills.skillStates.notion + const notionSkillState = skillStates?.notion as Record | undefined; + useEffect(() => { + if (!notionSkillState) return; + syncNotionStateToSlice(notionSkillState, dispatch); + }, [notionSkillState, dispatch]); + // When Notion connection_status transitions to "connected", fetch the current user // via the notion get-user tool, store it in notionSlice, and sync metadata to backend. - const notionSkillState = skillStates?.notion as Record | undefined; useEffect(() => { if (!notionSkillState || typeof notionSkillState !== 'object') return; const connectionStatus = notionSkillState.connection_status as string | undefined; @@ -157,14 +184,20 @@ export default function SkillProvider({ children }: { children: ReactNode }) { listen<{ skillId: string; state: Record }>('skill-state-changed', event => { const parsed = parseSkillStatePayload(event.payload); + console.log('๐Ÿš€ ~ SkillProvider ~ parsed:', parsed); if (!parsed) return; const { skillId, state: newState } = parsed; + console.log('๐Ÿš€ ~ SkillProvider ~ newState:', skillId, newState); dispatch(setSkillState({ skillId, state: newState })); // Transfer Gmail skill state to gmail store (also synced by effect from skillStates.gmail) if (skillId === 'gmail') { syncGmailStateToSlice(newState, dispatch); } + // Transfer Notion skill state to notion store (also synced by effect from skillStates.notion) + if (skillId === 'notion') { + syncNotionStateToSlice(newState, dispatch); + } }) .then(fn => { unlisten = fn; diff --git a/src/store/notionSlice.ts b/src/store/notionSlice.ts index d824c6d7c..3009d1781 100644 --- a/src/store/notionSlice.ts +++ b/src/store/notionSlice.ts @@ -8,12 +8,37 @@ export interface NotionUserProfile { avatar_url?: string | null; } +export interface NotionPageSummary { + id: string; + title: string; + url: string | null; + last_edited_time: string; + content_text: string | null; +} + +/** AI-generated summary for a Notion page, published by the notion skill. */ +export interface NotionSummary { + id: number; + pageId: string; + url: string | null; + summary: string; + category: string | null; + sentiment: string; + topics: string[]; + sourceCreatedAt: string; + sourceUpdatedAt: string; +} + interface NotionState { /** Profile of the connected Notion user (from Notion skill) */ profile: NotionUserProfile | null; + /** Pages fetched after OAuth connection (from Notion skill) */ + pages: NotionPageSummary[]; + /** AI-generated page summaries published by the Notion skill */ + summaries: NotionSummary[]; } -const initialState: NotionState = { profile: null }; +const initialState: NotionState = { profile: null, pages: [], summaries: [] }; const notionSlice = createSlice({ name: 'notion', @@ -25,9 +50,33 @@ const notionSlice = createSlice({ clearNotionProfile(state) { state.profile = null; }, + setNotionPages(state, action: PayloadAction) { + state.pages = action.payload; + }, + clearNotionPages(state) { + state.pages = []; + }, + setNotionSummaries(state, action: PayloadAction) { + state.summaries = action.payload; + }, + clearNotionSummaries(state) { + state.summaries = []; + }, + clearNotionData(state) { + state.profile = null; + state.pages = []; + state.summaries = []; + }, }, }); -export const { setNotionProfile, clearNotionProfile } = notionSlice.actions; +export const { + setNotionProfile, + clearNotionProfile, + setNotionPages, + clearNotionPages, + setNotionSummaries, + clearNotionSummaries, + clearNotionData, +} = notionSlice.actions; export default notionSlice.reducer; - diff --git a/src/store/threadSlice.ts b/src/store/threadSlice.ts index d736e62f9..890262256 100644 --- a/src/store/threadSlice.ts +++ b/src/store/threadSlice.ts @@ -227,9 +227,17 @@ const threadSlice = createSlice({ const persistedMessages = state.messagesByThreadId[state.selectedThreadId]; const userMessageExists = persistedMessages.some(m => m.id === lastUserMessage.id); - // If user message isn't persisted yet, add it first + // If user message isn't persisted yet, add it first. + // Replace optimistic- prefix with a stable id so historySnapshot + // (which filters out optimistic- ids) includes it on future sends. if (!userMessageExists) { - persistedMessages.push(lastUserMessage); + const stableMessage = lastUserMessage.id.startsWith('optimistic-') + ? { ...lastUserMessage, id: `msg_${Date.now()}_user` } + : lastUserMessage; + persistedMessages.push(stableMessage); + // Keep state.messages in sync with the stable id + const idx = state.messages.findLastIndex(m => m.id === lastUserMessage.id); + if (idx !== -1) state.messages[idx] = stableMessage; } }