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.
This commit is contained in:
M3gA-Mind
2026-03-09 21:15:47 +05:30
parent f91e409a0d
commit e24db395b8
4 changed files with 160 additions and 7 deletions
+63
View File
@@ -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<string, unknown> | undefined)?.workspaceName as string | null ?? null
);
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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',
+35 -2
View File
@@ -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<string, unknown> | undefined,
dispatch: ReturnType<typeof useAppDispatch>
): 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<typeof useAppDispatch>): Promise<void> {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> }>('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;
+52 -3
View File
@@ -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<NotionPageSummary[]>) {
state.pages = action.payload;
},
clearNotionPages(state) {
state.pages = [];
},
setNotionSummaries(state, action: PayloadAction<NotionSummary[]>) {
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;
+10 -2
View File
@@ -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;
}
}