mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user