mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-21 03:51:45 +08:00
feat(editor): extract chat runtime (#14937)
#### PR Dependency Tree * **PR #14937** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Centralized AI event system and a runtime powering chat sessions and actions. * **Improvements** * Chat UI (composer, messages, toolbar, tabs, panels) now syncs with runtime snapshots for more consistent state. * Improved session/tab lifecycle (create, fork, delete), context embedding status, and history handling. * More reliable send/stop/retry flows, better telemetry scoping, and clearer upgrade/login/insert-template prompts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,142 +0,0 @@
|
||||
import { WorkspaceLocalState } from '@affine/core/modules/workspace';
|
||||
import type { I18nInstance } from '@affine/i18n';
|
||||
import type { NotificationService } from '@blocksuite/affine/shared/services';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
const AI_CHAT_OPEN_TABS_KEY = 'aiChatOpenTabs';
|
||||
|
||||
// Pass `null` for `loadSession` to defer hydration until a real loader is ready.
|
||||
export function useAIChatOpenTabs<T extends { sessionId: string }>(
|
||||
loadSession: ((sessionId: string) => Promise<T | null | undefined>) | null
|
||||
): {
|
||||
openTabs: T[];
|
||||
setOpenTabs: Dispatch<SetStateAction<T[]>>;
|
||||
} {
|
||||
const workspaceLocalState = useService(WorkspaceLocalState);
|
||||
const [openTabs, setOpenTabsState] = useState<T[]>([]);
|
||||
// Ref so persist gate isn't subject to React state-batch ordering.
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadSession) return;
|
||||
hydratedRef.current = false;
|
||||
setOpenTabsState([]);
|
||||
|
||||
const ids = workspaceLocalState.get<string[]>(AI_CHAT_OPEN_TABS_KEY) ?? [];
|
||||
if (!ids.length) {
|
||||
hydratedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Promise.all(ids.map(id => loadSession(id).catch(() => null)))
|
||||
.then(results => {
|
||||
if (cancelled) return;
|
||||
const valid = (results as (T | null | undefined)[]).filter(
|
||||
(entry): entry is T => !!entry && !!entry.sessionId
|
||||
);
|
||||
if (valid.length) setOpenTabsState(valid);
|
||||
hydratedRef.current = true;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
if (!cancelled) hydratedRef.current = true;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadSession, workspaceLocalState]);
|
||||
|
||||
const setOpenTabs = useCallback<Dispatch<SetStateAction<T[]>>>(
|
||||
updater => {
|
||||
setOpenTabsState(prev => {
|
||||
const next =
|
||||
typeof updater === 'function'
|
||||
? (updater as (p: T[]) => T[])(prev)
|
||||
: updater;
|
||||
if (hydratedRef.current) {
|
||||
if (next.length) {
|
||||
workspaceLocalState.set(
|
||||
AI_CHAT_OPEN_TABS_KEY,
|
||||
next.map(tab => tab.sessionId)
|
||||
);
|
||||
} else {
|
||||
workspaceLocalState.del(AI_CHAT_OPEN_TABS_KEY);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[workspaceLocalState]
|
||||
);
|
||||
|
||||
return { openTabs, setOpenTabs };
|
||||
}
|
||||
|
||||
export type SessionDeleteCleanupFn = (
|
||||
session: BlockSuitePresets.AIRecentSession
|
||||
) => Promise<void>;
|
||||
|
||||
export type CreateSessionDeleteHandlerOptions = {
|
||||
t: I18nInstance;
|
||||
notificationService: NotificationService;
|
||||
cleanupSession: SessionDeleteCleanupFn;
|
||||
canDeleteSession?: (session: BlockSuitePresets.AIRecentSession) => boolean;
|
||||
isActiveSession?: (session: BlockSuitePresets.AIRecentSession) => boolean;
|
||||
onActiveSessionDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function createSessionDeleteHandler({
|
||||
t,
|
||||
notificationService,
|
||||
cleanupSession,
|
||||
canDeleteSession,
|
||||
isActiveSession,
|
||||
onActiveSessionDeleted,
|
||||
}: CreateSessionDeleteHandlerOptions) {
|
||||
return async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||
if (canDeleteSession && !canDeleteSession(sessionToDelete)) {
|
||||
notificationService.toast(
|
||||
t['com.affine.ai.chat-panel.session.delete.toast.failed']()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = await notificationService.confirm({
|
||||
title: t['com.affine.ai.chat-panel.session.delete.confirm.title'](),
|
||||
message: t['com.affine.ai.chat-panel.session.delete.confirm.message'](),
|
||||
confirmText: t['Delete'](),
|
||||
cancelText: t['Cancel'](),
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession(sessionToDelete);
|
||||
notificationService.toast(
|
||||
t['com.affine.ai.chat-panel.session.delete.toast.success']()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notificationService.toast(
|
||||
t['com.affine.ai.chat-panel.session.delete.toast.failed']()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveSession?.(sessionToDelete)) {
|
||||
onActiveSessionDeleted?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { observeResize, useConfirmModal } from '@affine/component';
|
||||
import { CopilotClient } from '@affine/core/blocksuite/ai';
|
||||
import {
|
||||
AIChatContent,
|
||||
type ChatContextValue,
|
||||
} from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
||||
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
|
||||
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
||||
AIChatRuntime,
|
||||
createAIRequestService,
|
||||
useAIChatElement,
|
||||
useAIChatRuntime,
|
||||
WorkspaceAIChatSessionStrategy,
|
||||
} from '@affine/core/blocksuite/ai';
|
||||
import { AIChatContent } from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
||||
import {
|
||||
AIChatTabs,
|
||||
AIChatToolbar,
|
||||
configureAIChatToolbar,
|
||||
getOrCreateAIChatToolbar,
|
||||
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
||||
import type { PromptKey } from '@affine/core/blocksuite/ai/provider/prompt';
|
||||
import { getViewManager } from '@affine/core/blocksuite/manager/view';
|
||||
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
@@ -50,20 +50,18 @@ import { useFramework, useService } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
createSessionDeleteHandler,
|
||||
useAIChatOpenTabs,
|
||||
} from '../chat-panel-utils';
|
||||
import * as styles from './index.css';
|
||||
|
||||
type CopilotSession = Awaited<ReturnType<CopilotClient['getSession']>>;
|
||||
|
||||
function useCopilotClient() {
|
||||
function useAIRequestService() {
|
||||
const graphqlService = useService(GraphQLService);
|
||||
const eventSourceService = useService(EventSourceService);
|
||||
|
||||
return useMemo(
|
||||
() => new CopilotClient(graphqlService.gql, eventSourceService.eventSource),
|
||||
() =>
|
||||
createAIRequestService(
|
||||
graphqlService.gql,
|
||||
eventSourceService.eventSource
|
||||
),
|
||||
[graphqlService, eventSourceService]
|
||||
);
|
||||
}
|
||||
@@ -95,164 +93,32 @@ export const Component = () => {
|
||||
const framework = useFramework();
|
||||
const [isBodyProvided, setIsBodyProvided] = useState(false);
|
||||
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
|
||||
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
|
||||
const [chatTool, setChatTool] = useState<AIChatToolbar | null>(null);
|
||||
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
|
||||
const [currentSession, setCurrentSession] = useState<CopilotSession | null>(
|
||||
null
|
||||
);
|
||||
const [status, setStatus] = useState<ChatStatus>('idle');
|
||||
const [isTogglingPin, setIsTogglingPin] = useState(false);
|
||||
const [isOpeningSession, setIsOpeningSession] = useState(false);
|
||||
const hasRestoredPinnedSessionRef = useRef(false);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chatToolContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const widthSignalRef = useRef<Signal<number>>(signal(0));
|
||||
const client = useCopilotClient();
|
||||
const requestService = useAIRequestService();
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
const workspaceId = useService(WorkspaceService).workspace.id;
|
||||
|
||||
const loadSession = useCallback(
|
||||
(sessionId: string) => client.getSession(workspaceId, sessionId),
|
||||
[client, workspaceId]
|
||||
const runtime = useMemo(
|
||||
() =>
|
||||
new AIChatRuntime({
|
||||
request: requestService,
|
||||
scope: { kind: 'workspace', workspaceId },
|
||||
strategy: new WorkspaceAIChatSessionStrategy(),
|
||||
}),
|
||||
[requestService, workspaceId]
|
||||
);
|
||||
const { openTabs, setOpenTabs } = useAIChatOpenTabs(loadSession);
|
||||
|
||||
useEffect(() => {
|
||||
hasRestoredPinnedSessionRef.current = false;
|
||||
}, [workspaceId]);
|
||||
|
||||
const snapshot = useAIChatRuntime(runtime);
|
||||
const session =
|
||||
snapshot?.sessions.find(
|
||||
session => session.sessionId === snapshot.activeSessionId
|
||||
) ?? null;
|
||||
const { docDisplayConfig, searchMenuConfig, reasoningConfig } =
|
||||
useAIChatConfig();
|
||||
|
||||
const createSession = useCallback(
|
||||
async (options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}) => {
|
||||
if (currentSession) {
|
||||
return currentSession;
|
||||
}
|
||||
const session = await client.createSessionWithHistory({
|
||||
workspaceId,
|
||||
promptName: 'Chat With AFFiNE AI' satisfies PromptKey,
|
||||
reuseLatestChat: false,
|
||||
...options,
|
||||
});
|
||||
setCurrentSession(session);
|
||||
return session;
|
||||
},
|
||||
[client, currentSession, workspaceId]
|
||||
);
|
||||
|
||||
const togglePin = useCallback(async () => {
|
||||
if (isTogglingPin) return;
|
||||
setIsTogglingPin(true);
|
||||
try {
|
||||
const pinned = !currentSession?.pinned;
|
||||
if (!currentSession) {
|
||||
await createSession({ pinned });
|
||||
} else {
|
||||
await client.updateSession({
|
||||
sessionId: currentSession.sessionId,
|
||||
pinned,
|
||||
});
|
||||
// retrieve the latest session and update the state
|
||||
const session = await client.getSession(
|
||||
workspaceId,
|
||||
currentSession.sessionId
|
||||
);
|
||||
setCurrentSession(session);
|
||||
}
|
||||
} finally {
|
||||
setIsTogglingPin(false);
|
||||
}
|
||||
}, [client, createSession, currentSession, isTogglingPin, workspaceId]);
|
||||
|
||||
// remove the old content to trigger re-mount
|
||||
// to avoid infinitely load and mount, should not make `chatContent` as dependency
|
||||
const reMountChatContent = useCallback(() => {
|
||||
setChatContent(prev => {
|
||||
prev?.remove();
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createFreshSession = useCallback(async () => {
|
||||
if (isOpeningSession) {
|
||||
return;
|
||||
}
|
||||
setIsOpeningSession(true);
|
||||
try {
|
||||
setCurrentSession(null);
|
||||
reMountChatContent();
|
||||
const session = await client.createSessionWithHistory({
|
||||
workspaceId,
|
||||
promptName: 'Chat With AFFiNE AI' satisfies PromptKey,
|
||||
reuseLatestChat: false,
|
||||
});
|
||||
setCurrentSession(session);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsOpeningSession(false);
|
||||
}
|
||||
}, [client, isOpeningSession, reMountChatContent, workspaceId]);
|
||||
|
||||
const onOpenSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (isOpeningSession || currentSession?.sessionId === sessionId) return;
|
||||
setIsOpeningSession(true);
|
||||
try {
|
||||
const session = await client.getSession(workspaceId, sessionId);
|
||||
if (!session) {
|
||||
// Drop stale tab if session no longer exists.
|
||||
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
|
||||
return;
|
||||
}
|
||||
setCurrentSession(session);
|
||||
reMountChatContent();
|
||||
chatTool?.closeHistoryMenu();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsOpeningSession(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
chatTool,
|
||||
client,
|
||||
currentSession?.sessionId,
|
||||
isOpeningSession,
|
||||
reMountChatContent,
|
||||
setOpenTabs,
|
||||
workspaceId,
|
||||
]
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(sessionId: string) => {
|
||||
let fallback: NonNullable<CopilotSession> | undefined;
|
||||
setOpenTabs(prev => {
|
||||
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
|
||||
if (idx === -1) return prev;
|
||||
const next = prev.filter(tab => tab.sessionId !== sessionId);
|
||||
fallback = next[idx] ?? next[idx - 1] ?? next[0];
|
||||
return next;
|
||||
});
|
||||
if (currentSession?.sessionId !== sessionId) return;
|
||||
if (fallback) {
|
||||
onOpenSession(fallback.sessionId).catch(console.error);
|
||||
} else {
|
||||
createFreshSession().catch(console.error);
|
||||
}
|
||||
},
|
||||
[createFreshSession, currentSession?.sessionId, onOpenSession, setOpenTabs]
|
||||
);
|
||||
|
||||
const onContextChange = useCallback((context: Partial<ChatContextValue>) => {
|
||||
setStatus(context.status ?? 'idle');
|
||||
}, []);
|
||||
|
||||
const onOpenDoc = useCallback(
|
||||
(docId: string) => {
|
||||
workbench.openDoc(docId, { at: 'active' });
|
||||
@@ -283,143 +149,86 @@ export const Component = () => {
|
||||
const mockStd = useMockStd();
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
const deleteSession = useMemo(
|
||||
() =>
|
||||
createSessionDeleteHandler({
|
||||
t,
|
||||
notificationService,
|
||||
cleanupSession: async sessionToDelete => {
|
||||
await client.cleanupSessions({
|
||||
workspaceId: sessionToDelete.workspaceId,
|
||||
docId: sessionToDelete.docId || undefined,
|
||||
sessionIds: [sessionToDelete.sessionId],
|
||||
});
|
||||
},
|
||||
isActiveSession: sessionToDelete =>
|
||||
sessionToDelete.sessionId === currentSession?.sessionId,
|
||||
onActiveSessionDeleted: () => {
|
||||
setCurrentSession(null);
|
||||
reMountChatContent();
|
||||
},
|
||||
}),
|
||||
[
|
||||
client,
|
||||
currentSession?.sessionId,
|
||||
notificationService,
|
||||
reMountChatContent,
|
||||
t,
|
||||
]
|
||||
const deleteSession = useCallback(
|
||||
async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||
const confirm = await notificationService.confirm({
|
||||
title: t['com.affine.ai.chat-panel.session.delete.confirm.title'](),
|
||||
message: t['com.affine.ai.chat-panel.session.delete.confirm.message'](),
|
||||
confirmText: t['Delete'](),
|
||||
cancelText: t['Cancel'](),
|
||||
});
|
||||
if (!confirm) return;
|
||||
await runtime.dispatch({
|
||||
type: 'deleteSession',
|
||||
sessionId: sessionToDelete.sessionId,
|
||||
});
|
||||
notificationService.toast(
|
||||
t['com.affine.ai.chat-panel.session.delete.toast.success'](),
|
||||
{}
|
||||
);
|
||||
},
|
||||
[notificationService, runtime, t]
|
||||
);
|
||||
|
||||
// init or update ai-chat-content
|
||||
useEffect(() => {
|
||||
if (!isBodyProvided) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = chatContent;
|
||||
|
||||
if (!content) {
|
||||
content = new AIChatContent();
|
||||
}
|
||||
|
||||
content.session = currentSession;
|
||||
content.workspaceId = workspaceId;
|
||||
content.extensions = specs;
|
||||
content.host = mockStd?.host;
|
||||
content.docDisplayConfig = docDisplayConfig;
|
||||
content.searchMenuConfig = searchMenuConfig;
|
||||
content.reasoningConfig = reasoningConfig;
|
||||
content.onContextChange = onContextChange;
|
||||
content.affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||
content.affineWorkspaceDialogService = framework.get(
|
||||
WorkspaceDialogService
|
||||
);
|
||||
content.peekViewService = framework.get(PeekViewService);
|
||||
content.affineThemeService = framework.get(AppThemeService);
|
||||
content.notificationService = notificationService;
|
||||
content.aiDraftService = framework.get(AIDraftService);
|
||||
content.aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
content.serverService = framework.get(ServerService);
|
||||
content.subscriptionService = framework.get(SubscriptionService);
|
||||
content.aiModelService = framework.get(AIModelService);
|
||||
content.onAISubscribe = handleAISubscribe;
|
||||
|
||||
content.createSession = createSession;
|
||||
content.onOpenDoc = onOpenDoc;
|
||||
|
||||
if (!chatContent) {
|
||||
// initial values that won't change
|
||||
useAIChatElement({
|
||||
containerRef: chatContainerRef,
|
||||
selector: 'ai-chat-content',
|
||||
enabled: isBodyProvided,
|
||||
createElement: () => new AIChatContent(),
|
||||
configureElement: content => {
|
||||
content.session = session;
|
||||
content.runtime = runtime;
|
||||
content.runtimeSnapshot = snapshot;
|
||||
content.workspaceId = workspaceId;
|
||||
content.extensions = specs;
|
||||
content.host = mockStd?.host;
|
||||
content.docDisplayConfig = docDisplayConfig;
|
||||
content.searchMenuConfig = searchMenuConfig;
|
||||
content.reasoningConfig = reasoningConfig;
|
||||
content.affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||
content.affineWorkspaceDialogService = framework.get(
|
||||
WorkspaceDialogService
|
||||
);
|
||||
content.peekViewService = framework.get(PeekViewService);
|
||||
content.affineThemeService = framework.get(AppThemeService);
|
||||
content.notificationService = notificationService;
|
||||
content.aiDraftService = framework.get(AIDraftService);
|
||||
content.aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
content.serverService = framework.get(ServerService);
|
||||
content.subscriptionService = framework.get(SubscriptionService);
|
||||
content.aiModelService = framework.get(AIModelService);
|
||||
content.onAISubscribe = handleAISubscribe;
|
||||
content.onOpenDoc = onOpenDoc;
|
||||
},
|
||||
onElementReady: content => {
|
||||
content.independentMode = true;
|
||||
content.onboardingOffsetY = -100;
|
||||
chatContainerRef.current?.append(content);
|
||||
setChatContent(content);
|
||||
}
|
||||
}, [
|
||||
chatContent,
|
||||
createSession,
|
||||
currentSession,
|
||||
docDisplayConfig,
|
||||
framework,
|
||||
isBodyProvided,
|
||||
mockStd,
|
||||
reasoningConfig,
|
||||
searchMenuConfig,
|
||||
workspaceId,
|
||||
onContextChange,
|
||||
notificationService,
|
||||
specs,
|
||||
onOpenDoc,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
// init or update header ai-chat-toolbar
|
||||
useEffect(() => {
|
||||
if (!isHeaderProvided || !chatToolContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
const tool = getOrCreateAIChatToolbar(chatTool);
|
||||
configureAIChatToolbar(tool, {
|
||||
session: currentSession,
|
||||
workspaceId,
|
||||
status,
|
||||
docDisplayConfig,
|
||||
notificationService,
|
||||
onOpenSession: sessionId => {
|
||||
onOpenSession(sessionId).catch(console.error);
|
||||
},
|
||||
onNewSession: () => {
|
||||
createFreshSession().catch(console.error);
|
||||
},
|
||||
onTogglePin: togglePin,
|
||||
onOpenDoc: (docId: string, sessionId: string) => {
|
||||
onOpenSessionDoc(docId, sessionId);
|
||||
},
|
||||
onSessionDelete: (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||
deleteSession(sessionToDelete).catch(console.error);
|
||||
},
|
||||
});
|
||||
|
||||
// initial props
|
||||
if (!chatTool) {
|
||||
// mount
|
||||
chatToolContainerRef.current.append(tool);
|
||||
setChatTool(tool);
|
||||
}
|
||||
}, [
|
||||
chatTool,
|
||||
currentSession,
|
||||
docDisplayConfig,
|
||||
isHeaderProvided,
|
||||
onOpenSession,
|
||||
togglePin,
|
||||
workspaceId,
|
||||
onOpenSessionDoc,
|
||||
deleteSession,
|
||||
status,
|
||||
notificationService,
|
||||
createFreshSession,
|
||||
]);
|
||||
useAIChatElement({
|
||||
containerRef: chatToolContainerRef,
|
||||
selector: 'ai-chat-toolbar',
|
||||
enabled: isHeaderProvided,
|
||||
createElement: () => new AIChatToolbar(),
|
||||
configureElement: tool => {
|
||||
configureAIChatToolbar(tool, {
|
||||
session,
|
||||
runtime,
|
||||
runtimeSnapshot: snapshot ?? runtime.getSnapshot(),
|
||||
docDisplayConfig,
|
||||
notificationService,
|
||||
onOpenDoc: (docId: string, sessionId: string) => {
|
||||
onOpenSessionDoc(docId, sessionId);
|
||||
},
|
||||
onSessionDelete: (
|
||||
sessionToDelete: BlockSuitePresets.AIRecentSession
|
||||
) => {
|
||||
deleteSession(sessionToDelete).catch(console.error);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const refNodeSlots = mockStd?.getOptional(RefNodeSlotsProvider);
|
||||
@@ -437,87 +246,16 @@ export const Component = () => {
|
||||
return () => sub.unsubscribe();
|
||||
}, [framework, mockStd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession?.sessionId) return;
|
||||
setOpenTabs(prev => {
|
||||
const existing = prev.findIndex(
|
||||
tab => tab.sessionId === currentSession.sessionId
|
||||
);
|
||||
if (existing !== -1) {
|
||||
if (prev[existing] === currentSession) return prev;
|
||||
const next = prev.slice();
|
||||
next[existing] = currentSession;
|
||||
return next;
|
||||
}
|
||||
return [...prev, currentSession];
|
||||
});
|
||||
}, [currentSession, setOpenTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatTabsContainerRef.current) return;
|
||||
let tabs = chatTabs;
|
||||
if (!tabs) {
|
||||
tabs = new AIChatTabs();
|
||||
chatTabsContainerRef.current.append(tabs);
|
||||
setChatTabs(tabs);
|
||||
}
|
||||
tabs.sessions = openTabs;
|
||||
tabs.activeSessionId = currentSession?.sessionId;
|
||||
tabs.onSelectTab = (sessionId: string) => {
|
||||
onOpenSession(sessionId).catch(console.error);
|
||||
};
|
||||
tabs.onCloseTab = (sessionId: string) => {
|
||||
closeTab(sessionId);
|
||||
};
|
||||
}, [chatTabs, closeTab, currentSession?.sessionId, onOpenSession, openTabs]);
|
||||
|
||||
// restore pinned session
|
||||
useEffect(() => {
|
||||
if (hasRestoredPinnedSessionRef.current || currentSession) return;
|
||||
hasRestoredPinnedSessionRef.current = true;
|
||||
|
||||
const controller = new AbortController();
|
||||
const loadPinnedSession = async () => {
|
||||
try {
|
||||
const sessions = await client.getSessions(
|
||||
workspaceId,
|
||||
{},
|
||||
undefined,
|
||||
{ pinned: true, limit: 1 },
|
||||
controller.signal
|
||||
);
|
||||
if (controller.signal.aborted || !Array.isArray(sessions)) {
|
||||
return;
|
||||
}
|
||||
const pinnedSession = sessions[0];
|
||||
if (!pinnedSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldRemount = false;
|
||||
setCurrentSession(prev => {
|
||||
if (prev) return prev;
|
||||
shouldRemount = true;
|
||||
return pinnedSession;
|
||||
});
|
||||
if (shouldRemount) reMountChatContent();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
loadPinnedSession().catch(error => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
// abort the request
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [client, currentSession, reMountChatContent, workspaceId]);
|
||||
useAIChatElement({
|
||||
containerRef: chatTabsContainerRef,
|
||||
selector: 'ai-chat-tabs',
|
||||
enabled: true,
|
||||
createElement: () => new AIChatTabs(),
|
||||
configureElement: tabs => {
|
||||
tabs.runtime = runtime;
|
||||
tabs.runtimeSnapshot = snapshot;
|
||||
},
|
||||
});
|
||||
|
||||
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
|
||||
if (node) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { PageDetailLoading } from '@affine/component/page-detail-skeleton';
|
||||
import type { AIChatParams } from '@affine/core/blocksuite/ai';
|
||||
import { AIProvider } from '@affine/core/blocksuite/ai';
|
||||
import { AIAppEvents, type AIChatParams } from '@affine/core/blocksuite/ai';
|
||||
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
||||
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
|
||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||
@@ -145,12 +144,8 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
workbench.openSidebar();
|
||||
view.activeSidebarTab('chat');
|
||||
};
|
||||
disposables.push(
|
||||
AIProvider.slots.requestOpenWithChat.subscribe(openHandler)
|
||||
);
|
||||
disposables.push(
|
||||
AIProvider.slots.requestSendWithChat.subscribe(openHandler)
|
||||
);
|
||||
disposables.push(AIAppEvents.requestOpenWithChat.subscribe(openHandler));
|
||||
disposables.push(AIAppEvents.requestSendWithChat.subscribe(openHandler));
|
||||
return () => disposables.forEach(d => d.unsubscribe());
|
||||
}, [activeSidebarTab, view, workbench]);
|
||||
|
||||
@@ -378,7 +373,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
icon={<AiIcon />}
|
||||
unmountOnInactive={false}
|
||||
>
|
||||
<EditorChatPanel editor={editorContainer} />
|
||||
<EditorChatPanel editor={editorContainer} doc={doc.blockSuiteDoc} />
|
||||
</ViewSidebarTab>
|
||||
)}
|
||||
|
||||
|
||||
-341
@@ -1,341 +0,0 @@
|
||||
/* eslint-disable rxjs/finnish */
|
||||
import { type CopilotChatHistoryFragment } from '@affine/graphql';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
canCreateNewDocPanelSession,
|
||||
filterDocPanelTabs,
|
||||
getChatContentKey,
|
||||
hasSessionMessages,
|
||||
isSessionAvailableInDocPanel,
|
||||
resolveInitialSession,
|
||||
type SessionService,
|
||||
shouldResetChatPanelOnUserInfoChange,
|
||||
type WorkbenchLike,
|
||||
} from './chat-panel-session';
|
||||
|
||||
const createWorkbench = (search: string) => {
|
||||
const updateQueryString = vi.fn();
|
||||
const workbench = {
|
||||
location$: { value: { search } },
|
||||
activeView$: { value: { updateQueryString } },
|
||||
} satisfies WorkbenchLike;
|
||||
|
||||
return { workbench, updateQueryString };
|
||||
};
|
||||
|
||||
const doc = { id: 'doc-1', workspace: { id: 'ws-1' } };
|
||||
|
||||
describe('getChatContentKey', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'uses doc id before a session is created',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
session: null,
|
||||
},
|
||||
expected: 'doc-1',
|
||||
},
|
||||
{
|
||||
name: 'keeps a new empty doc session on the doc key',
|
||||
input: {
|
||||
docId: 'doc-2',
|
||||
hasPinned: false,
|
||||
previousSessionDocId: 'doc-1',
|
||||
previousSessionId: 'session-1',
|
||||
session: {
|
||||
sessionId: 'session-2',
|
||||
docId: 'doc-2',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'doc-2',
|
||||
},
|
||||
{
|
||||
name: 'uses session id for a session with history',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
docId: 'doc-1',
|
||||
messages: [{ id: 'message-1' }],
|
||||
},
|
||||
},
|
||||
expected: 'session-1',
|
||||
},
|
||||
{
|
||||
name: 'uses session id for a pinned session',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: true,
|
||||
session: {
|
||||
sessionId: 'session-1',
|
||||
docId: 'doc-1',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'session-1',
|
||||
},
|
||||
{
|
||||
name: 'uses session id for same-doc session switch',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
previousSessionDocId: 'doc-1',
|
||||
previousSessionId: 'session-1',
|
||||
session: {
|
||||
sessionId: 'session-2',
|
||||
docId: 'doc-1',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'session-2',
|
||||
},
|
||||
{
|
||||
name: 'keeps generating draft session on the doc key',
|
||||
input: {
|
||||
docId: 'doc-1',
|
||||
hasPinned: false,
|
||||
isGenerating: true,
|
||||
previousSessionDocId: 'doc-1',
|
||||
previousSessionId: 'session-1',
|
||||
session: {
|
||||
sessionId: 'session-2',
|
||||
docId: 'doc-1',
|
||||
messages: [],
|
||||
},
|
||||
},
|
||||
expected: 'doc-1',
|
||||
},
|
||||
] satisfies {
|
||||
name: string;
|
||||
input: Parameters<typeof getChatContentKey>[0];
|
||||
expected: string;
|
||||
}[];
|
||||
|
||||
test.each(cases)('$name', ({ input, expected }) => {
|
||||
expect(getChatContentKey(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldResetChatPanelOnUserInfoChange', () => {
|
||||
const cases = [
|
||||
{
|
||||
name: 'ignores the initial user info emission',
|
||||
input: {
|
||||
previousUserId: undefined,
|
||||
nextUserId: 'user-1',
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'ignores same-user refreshes',
|
||||
input: {
|
||||
previousUserId: 'user-1',
|
||||
nextUserId: 'user-1',
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'resets when the effective user changes',
|
||||
input: {
|
||||
previousUserId: 'user-1',
|
||||
nextUserId: 'user-2',
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'resets when the effective user signs out',
|
||||
input: {
|
||||
previousUserId: 'user-1',
|
||||
nextUserId: null,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
] satisfies {
|
||||
name: string;
|
||||
input: Parameters<typeof shouldResetChatPanelOnUserInfoChange>[0];
|
||||
expected: boolean;
|
||||
}[];
|
||||
|
||||
test.each(cases)('$name', ({ input, expected }) => {
|
||||
expect(shouldResetChatPanelOnUserInfoChange(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doc panel tabs', () => {
|
||||
const sessions = [
|
||||
{ sessionId: 'current-doc-session', docId: 'doc-1' },
|
||||
{ sessionId: 'workspace-session', docId: null },
|
||||
{ sessionId: 'other-doc-session', docId: 'doc-2' },
|
||||
];
|
||||
|
||||
test('allows only current doc or workspace sessions', () => {
|
||||
expect(filterDocPanelTabs(sessions, 'doc-1')).toEqual([
|
||||
sessions[0],
|
||||
sessions[1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects other document sessions', () => {
|
||||
expect(isSessionAvailableInDocPanel(sessions[2], 'doc-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('new session guard', () => {
|
||||
test('allows a new session only after the current chat has messages', () => {
|
||||
expect(
|
||||
canCreateNewDocPanelSession({
|
||||
hasContextMessages: false,
|
||||
session: { messages: [] },
|
||||
status: 'idle',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
canCreateNewDocPanelSession({
|
||||
hasContextMessages: true,
|
||||
session: { messages: [] },
|
||||
status: 'idle',
|
||||
})
|
||||
).toBe(true);
|
||||
expect(hasSessionMessages({ messages: [{ id: 'message-1' }] })).toBe(true);
|
||||
});
|
||||
|
||||
test('does not allow a new session while generating', () => {
|
||||
expect(
|
||||
canCreateNewDocPanelSession({
|
||||
hasContextMessages: true,
|
||||
session: null,
|
||||
status: 'loading',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns undefined without session service or doc', async () => {
|
||||
await expect(
|
||||
resolveInitialSession({ sessionService: null, doc, workbench: null })
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
resolveInitialSession({
|
||||
sessionService: {
|
||||
getSessions: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
doc: null,
|
||||
workbench: null,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
describe('resolveInitialSession', () => {
|
||||
test('prefers pinned session and clears sessionId from url', async () => {
|
||||
const pinnedSession = {
|
||||
sessionId: 'pinned-session',
|
||||
pinned: true,
|
||||
} as CopilotChatHistoryFragment;
|
||||
|
||||
const sessionService: SessionService = {
|
||||
getSessions: vi.fn().mockResolvedValueOnce([pinnedSession]),
|
||||
getSession: vi.fn(),
|
||||
};
|
||||
|
||||
const { workbench, updateQueryString } = createWorkbench(
|
||||
'?sessionId=from-url'
|
||||
);
|
||||
|
||||
const result = await resolveInitialSession({
|
||||
sessionService,
|
||||
doc,
|
||||
workbench,
|
||||
});
|
||||
|
||||
expect(result).toBe(pinnedSession);
|
||||
expect(updateQueryString).toHaveBeenCalledWith(
|
||||
{ sessionId: undefined },
|
||||
{ replace: true }
|
||||
);
|
||||
expect(sessionService.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('loads session from url when no pinned session', async () => {
|
||||
const sessionFromUrl = {
|
||||
sessionId: 'url-session',
|
||||
pinned: false,
|
||||
} as CopilotChatHistoryFragment;
|
||||
|
||||
const sessionService: SessionService = {
|
||||
getSessions: vi.fn().mockResolvedValueOnce([]),
|
||||
getSession: vi.fn().mockResolvedValueOnce(sessionFromUrl),
|
||||
};
|
||||
|
||||
const { workbench, updateQueryString } = createWorkbench(
|
||||
'?sessionId=url-session'
|
||||
);
|
||||
|
||||
const result = await resolveInitialSession({
|
||||
sessionService,
|
||||
doc,
|
||||
workbench,
|
||||
});
|
||||
|
||||
expect(result).toBe(sessionFromUrl);
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(
|
||||
doc.workspace.id,
|
||||
'url-session'
|
||||
);
|
||||
expect(updateQueryString).toHaveBeenCalledWith(
|
||||
{ sessionId: undefined },
|
||||
{ replace: true }
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to latest doc session', async () => {
|
||||
const docSession = {
|
||||
sessionId: 'doc-session',
|
||||
pinned: false,
|
||||
} as CopilotChatHistoryFragment;
|
||||
|
||||
const sessionService: SessionService = {
|
||||
getSessions: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([docSession]),
|
||||
getSession: vi.fn(),
|
||||
};
|
||||
|
||||
const { workbench } = createWorkbench('');
|
||||
|
||||
const result = await resolveInitialSession({
|
||||
sessionService,
|
||||
doc,
|
||||
workbench,
|
||||
});
|
||||
|
||||
expect(result).toBe(docSession);
|
||||
expect(sessionService.getSessions).toHaveBeenCalledWith(
|
||||
doc.workspace.id,
|
||||
doc.id,
|
||||
{ action: false, fork: false, limit: 1 }
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when url session is missing', async () => {
|
||||
const sessionService: SessionService = {
|
||||
getSessions: vi.fn().mockResolvedValueOnce([]),
|
||||
getSession: vi.fn().mockResolvedValueOnce(null),
|
||||
};
|
||||
|
||||
const { workbench } = createWorkbench('?sessionId=missing');
|
||||
|
||||
const result = await resolveInitialSession({
|
||||
sessionService,
|
||||
doc,
|
||||
workbench,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
/* eslint-disable rxjs/finnish */
|
||||
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||
|
||||
type SessionListOptions = {
|
||||
pinned?: boolean;
|
||||
action?: boolean;
|
||||
fork?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export interface SessionService {
|
||||
getSessions: (
|
||||
workspaceId: string,
|
||||
docId?: string,
|
||||
options?: SessionListOptions
|
||||
) => Promise<CopilotChatHistoryFragment[] | null | undefined>;
|
||||
getSession: (
|
||||
workspaceId: string,
|
||||
sessionId: string
|
||||
) => Promise<CopilotChatHistoryFragment | null | undefined>;
|
||||
}
|
||||
|
||||
export interface WorkbenchLike {
|
||||
location$: {
|
||||
value: {
|
||||
search: string;
|
||||
};
|
||||
};
|
||||
activeView$: {
|
||||
value: {
|
||||
updateQueryString: (
|
||||
patch: Record<string, unknown>,
|
||||
options?: { replace?: boolean }
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocLike {
|
||||
id: string;
|
||||
workspace: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatContentKeySession {
|
||||
sessionId?: string | null;
|
||||
docId?: string | null;
|
||||
messages?: readonly unknown[] | null;
|
||||
}
|
||||
|
||||
type TabSession = {
|
||||
sessionId: string;
|
||||
docId?: string | null;
|
||||
messages?: readonly unknown[] | null;
|
||||
};
|
||||
|
||||
export const shouldResetChatPanelOnUserInfoChange = ({
|
||||
previousUserId,
|
||||
nextUserId,
|
||||
}: {
|
||||
previousUserId?: string | null;
|
||||
nextUserId?: string | null;
|
||||
}) => {
|
||||
return previousUserId !== undefined && previousUserId !== nextUserId;
|
||||
};
|
||||
|
||||
export const getChatContentKey = ({
|
||||
docId,
|
||||
hasPinned,
|
||||
isGenerating,
|
||||
previousSessionDocId,
|
||||
previousSessionId,
|
||||
session,
|
||||
}: {
|
||||
docId?: string | null;
|
||||
hasPinned: boolean;
|
||||
isGenerating?: boolean;
|
||||
previousSessionDocId?: string | null;
|
||||
previousSessionId?: string | null;
|
||||
session?: ChatContentKeySession | null;
|
||||
}) => {
|
||||
const fallbackKey = docId ?? 'chat-panel';
|
||||
const sessionId = session?.sessionId;
|
||||
if (!sessionId) {
|
||||
return fallbackKey;
|
||||
}
|
||||
|
||||
const sessionDocId = session.docId ?? docId ?? null;
|
||||
const hasSessionHistory = !!session.messages?.length;
|
||||
const shouldPreserveTransientMessages = isGenerating && !hasSessionHistory;
|
||||
const sessionSwitchedWithinDoc = !!(
|
||||
previousSessionId &&
|
||||
previousSessionId !== sessionId &&
|
||||
previousSessionDocId &&
|
||||
sessionDocId &&
|
||||
previousSessionDocId === sessionDocId &&
|
||||
sessionDocId === docId
|
||||
);
|
||||
|
||||
return hasPinned ||
|
||||
hasSessionHistory ||
|
||||
(sessionSwitchedWithinDoc && !shouldPreserveTransientMessages)
|
||||
? sessionId
|
||||
: fallbackKey;
|
||||
};
|
||||
|
||||
export const isSessionAvailableInDocPanel = (
|
||||
session: TabSession,
|
||||
docId?: string | null
|
||||
) => {
|
||||
return !session.docId || session.docId === docId;
|
||||
};
|
||||
|
||||
export const filterDocPanelTabs = <T extends TabSession>(
|
||||
sessions: T[],
|
||||
docId?: string | null
|
||||
) => {
|
||||
return sessions.filter(session =>
|
||||
isSessionAvailableInDocPanel(session, docId)
|
||||
);
|
||||
};
|
||||
|
||||
export const hasSessionMessages = (
|
||||
session?: Pick<TabSession, 'messages'> | null
|
||||
) => {
|
||||
return !!session?.messages?.length;
|
||||
};
|
||||
|
||||
export const canCreateNewDocPanelSession = ({
|
||||
hasContextMessages,
|
||||
session,
|
||||
status,
|
||||
}: {
|
||||
hasContextMessages: boolean;
|
||||
session?: Pick<TabSession, 'messages'> | null;
|
||||
status?: string | null;
|
||||
}) => {
|
||||
return (
|
||||
(hasContextMessages || hasSessionMessages(session)) &&
|
||||
status !== 'loading' &&
|
||||
status !== 'transmitting'
|
||||
);
|
||||
};
|
||||
|
||||
export const getSessionIdFromUrl = (workbench?: WorkbenchLike | null) => {
|
||||
if (!workbench) {
|
||||
return undefined;
|
||||
}
|
||||
const searchParams = new URLSearchParams(workbench.location$.value.search);
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
if (sessionId) {
|
||||
workbench.activeView$.value.updateQueryString(
|
||||
{ sessionId: undefined },
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
return sessionId ?? undefined;
|
||||
};
|
||||
|
||||
export const resolveInitialSession = async ({
|
||||
sessionService,
|
||||
doc,
|
||||
workbench,
|
||||
}: {
|
||||
sessionService?: SessionService | null;
|
||||
doc?: DocLike | null;
|
||||
workbench?: WorkbenchLike | null;
|
||||
}): Promise<CopilotChatHistoryFragment | null | undefined> => {
|
||||
if (!sessionService || !doc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionId = getSessionIdFromUrl(workbench);
|
||||
|
||||
const pinSessions = await sessionService.getSessions(
|
||||
doc.workspace.id,
|
||||
undefined,
|
||||
{
|
||||
pinned: true,
|
||||
limit: 1,
|
||||
}
|
||||
);
|
||||
|
||||
if (Array.isArray(pinSessions) && pinSessions[0]) {
|
||||
return pinSessions[0];
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const session = await sessionService.getSession(
|
||||
doc.workspace.id,
|
||||
sessionId
|
||||
);
|
||||
return session ?? null;
|
||||
}
|
||||
|
||||
const docSessions = await sessionService.getSessions(
|
||||
doc.workspace.id,
|
||||
doc.id,
|
||||
{
|
||||
action: false,
|
||||
fork: false,
|
||||
limit: 1,
|
||||
}
|
||||
);
|
||||
|
||||
return docSessions?.[0] ?? null;
|
||||
};
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useConfirmModal } from '@affine/component';
|
||||
import { AIProvider } from '@affine/core/blocksuite/ai';
|
||||
import type { AppSidebarConfig } from '@affine/core/blocksuite/ai/chat-panel/chat-config';
|
||||
import {
|
||||
AIChatContent,
|
||||
type ChatContextValue,
|
||||
} from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
||||
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
|
||||
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
||||
AIAppEvents,
|
||||
AIChatRuntime,
|
||||
createAIRequestService,
|
||||
DocAIChatSessionStrategy,
|
||||
useAIChatElement,
|
||||
useAIChatRuntime,
|
||||
} from '@affine/core/blocksuite/ai';
|
||||
import type { AppSidebarConfig } from '@affine/core/blocksuite/ai/chat-panel/chat-config';
|
||||
import { AIChatContent } from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
||||
import {
|
||||
AIChatTabs,
|
||||
AIChatToolbar,
|
||||
configureAIChatToolbar,
|
||||
getOrCreateAIChatToolbar,
|
||||
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
||||
import { createPlaygroundModal } from '@affine/core/blocksuite/ai/components/playground/modal';
|
||||
import { registerAIAppEffects } from '@affine/core/blocksuite/ai/effects/app';
|
||||
@@ -24,52 +26,55 @@ import {
|
||||
AIToolsConfigService,
|
||||
} from '@affine/core/modules/ai-button';
|
||||
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
||||
import { ServerService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
EventSourceService,
|
||||
GraphQLService,
|
||||
ServerService,
|
||||
SubscriptionService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { useSignalValue } from '@affine/core/modules/doc-info/utils';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||
import { AppThemeService } from '@affine/core/modules/theme';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import type {
|
||||
ContextEmbedStatus,
|
||||
CopilotChatHistoryFragment,
|
||||
UpdateChatSessionInput,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||
import { DocModeProvider } from '@blocksuite/affine/shared/services';
|
||||
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import { CenterPeekIcon, Logo1Icon } from '@blocksuite/icons/rc';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import { useFramework, useService } from '@toeverything/infra';
|
||||
import { html } from 'lit';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
createSessionDeleteHandler,
|
||||
useAIChatOpenTabs,
|
||||
} from '../../chat-panel-utils';
|
||||
import * as styles from './chat.css';
|
||||
import {
|
||||
canCreateNewDocPanelSession,
|
||||
filterDocPanelTabs,
|
||||
getChatContentKey,
|
||||
isSessionAvailableInDocPanel,
|
||||
resolveInitialSession,
|
||||
shouldResetChatPanelOnUserInfoChange,
|
||||
type WorkbenchLike,
|
||||
} from './chat-panel-session';
|
||||
|
||||
registerAIAppEffects();
|
||||
|
||||
const shouldResetChatPanelOnUserInfoChange = ({
|
||||
previousUserId,
|
||||
nextUserId,
|
||||
}: {
|
||||
previousUserId?: string | null;
|
||||
nextUserId?: string | null;
|
||||
}) => previousUserId !== undefined && previousUserId !== nextUserId;
|
||||
|
||||
export interface SidebarTabProps {
|
||||
editor: AffineEditorContainer | null;
|
||||
doc: Store;
|
||||
onLoad?: ((component: HTMLElement) => void) | null;
|
||||
}
|
||||
|
||||
export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
export const EditorChatPanel = ({
|
||||
editor,
|
||||
doc: fallbackDoc,
|
||||
onLoad,
|
||||
}: SidebarTabProps) => {
|
||||
const framework = useFramework();
|
||||
const graphqlService = useService(GraphQLService);
|
||||
const eventSourceService = useService(EventSourceService);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const t = useI18n();
|
||||
|
||||
@@ -89,81 +94,57 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
} = useAIChatConfig();
|
||||
const playgroundVisible = useSignalValue(playgroundConfig.visible) ?? false;
|
||||
|
||||
const [session, setSession] = useState<
|
||||
CopilotChatHistoryFragment | null | undefined
|
||||
>(undefined);
|
||||
const [embeddingProgress, setEmbeddingProgress] = useState<[number, number]>([
|
||||
0, 0,
|
||||
]);
|
||||
const [status, setStatus] = useState<ChatStatus>('idle');
|
||||
const [hasPinned, setHasPinned] = useState(false);
|
||||
|
||||
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
|
||||
const [chatToolbar, setChatToolbar] = useState<AIChatToolbar | null>(null);
|
||||
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
|
||||
const [isBodyProvided, setIsBodyProvided] = useState(false);
|
||||
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
|
||||
const chatContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentKeyRef = useRef<string | null>(null);
|
||||
const prevSessionIdRef = useRef<string | null>(null);
|
||||
const prevSessionDocIdRef = useRef<string | null>(null);
|
||||
const lastDocIdRef = useRef<string | null>(null);
|
||||
const sessionLoadSeqRef = useRef(0);
|
||||
const creatingSessionRef = useRef<{
|
||||
docId: string;
|
||||
promise: Promise<CopilotChatHistoryFragment | undefined>;
|
||||
} | null>(null);
|
||||
const creatingFreshSessionRef = useRef<{
|
||||
docId: string;
|
||||
promise: Promise<void>;
|
||||
} | null>(null);
|
||||
const userIdRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const doc = editor?.doc;
|
||||
const doc = editor?.doc ?? fallbackDoc;
|
||||
const host = editor?.host;
|
||||
const workspaceId = doc?.workspace.id;
|
||||
|
||||
const [sessionServiceReady, setSessionServiceReady] = useState(
|
||||
() => !!AIProvider.session
|
||||
const requestService = useMemo(
|
||||
() =>
|
||||
createAIRequestService(
|
||||
graphqlService.gql,
|
||||
eventSourceService.eventSource
|
||||
),
|
||||
[eventSourceService.eventSource, graphqlService.gql]
|
||||
);
|
||||
const [hasContextMessages, setHasContextMessages] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionServiceReady) return;
|
||||
if (AIProvider.session) {
|
||||
setSessionServiceReady(true);
|
||||
return;
|
||||
}
|
||||
const sub = AIProvider.slots.sessionReady.subscribe(ready => {
|
||||
if (ready) setSessionServiceReady(true);
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [sessionServiceReady]);
|
||||
|
||||
const loadSession = useMemo(() => {
|
||||
if (!sessionServiceReady || !workspaceId) return null;
|
||||
const sessionService = AIProvider.session;
|
||||
if (!sessionService) return null;
|
||||
return async (
|
||||
sessionId: string
|
||||
): Promise<CopilotChatHistoryFragment | null | undefined> =>
|
||||
sessionService.getSession(workspaceId, sessionId);
|
||||
}, [sessionServiceReady, workspaceId]);
|
||||
|
||||
const { openTabs, setOpenTabs } =
|
||||
useAIChatOpenTabs<CopilotChatHistoryFragment>(loadSession);
|
||||
const visibleOpenTabs = useMemo(
|
||||
() => filterDocPanelTabs(openTabs, doc?.id),
|
||||
[doc?.id, openTabs]
|
||||
);
|
||||
const canCreateNewSession = canCreateNewDocPanelSession({
|
||||
hasContextMessages,
|
||||
session,
|
||||
status,
|
||||
const [pendingSessionId] = useState(() => {
|
||||
const searchParams = new URLSearchParams(workbench.location$.value.search);
|
||||
return searchParams.get('sessionId') ?? undefined;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingSessionId) {
|
||||
workbench.activeView$.value.updateQueryString(
|
||||
{ sessionId: undefined },
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
}, [pendingSessionId, workbench]);
|
||||
|
||||
const runtime = useMemo(() => {
|
||||
if (!doc || !workspaceId) return null;
|
||||
return new AIChatRuntime({
|
||||
request: requestService,
|
||||
scope: {
|
||||
kind: 'doc',
|
||||
workspaceId,
|
||||
docId: doc.id,
|
||||
pendingSessionId,
|
||||
},
|
||||
strategy: new DocAIChatSessionStrategy(),
|
||||
});
|
||||
}, [doc, pendingSessionId, requestService, workspaceId]);
|
||||
const snapshot = useAIChatRuntime(runtime);
|
||||
const session =
|
||||
snapshot?.sessions.find(
|
||||
item => item.sessionId === snapshot.activeSessionId
|
||||
) ?? null;
|
||||
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
|
||||
return {
|
||||
getWidth: () =>
|
||||
@@ -188,159 +169,14 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
return cleanup;
|
||||
}, [appSidebarConfig]);
|
||||
|
||||
const resetPanel = useCallback(() => {
|
||||
sessionLoadSeqRef.current += 1;
|
||||
setSession(undefined);
|
||||
setEmbeddingProgress([0, 0]);
|
||||
setHasPinned(false);
|
||||
}, []);
|
||||
|
||||
const initPanel = useCallback(async () => {
|
||||
const requestSeq = ++sessionLoadSeqRef.current;
|
||||
try {
|
||||
const nextSession = await resolveInitialSession({
|
||||
sessionService: AIProvider.session ?? undefined,
|
||||
doc,
|
||||
workbench: workbench as WorkbenchLike,
|
||||
});
|
||||
|
||||
if (requestSeq !== sessionLoadSeqRef.current) return;
|
||||
if (nextSession === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(nextSession);
|
||||
setHasPinned(!!nextSession?.pinned);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [doc, workbench]);
|
||||
|
||||
const createSession = useCallback(
|
||||
async (options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}) => {
|
||||
if (session || !AIProvider.session || !doc) {
|
||||
return session ?? undefined;
|
||||
}
|
||||
if (creatingSessionRef.current?.docId === doc.id) {
|
||||
return creatingSessionRef.current.promise;
|
||||
}
|
||||
const requestSeq = ++sessionLoadSeqRef.current;
|
||||
let promise: Promise<CopilotChatHistoryFragment | undefined>;
|
||||
promise = AIProvider.session
|
||||
.createSessionWithHistory({
|
||||
docId: doc.id,
|
||||
workspaceId: doc.workspace.id,
|
||||
promptName: 'Chat With AFFiNE AI',
|
||||
reuseLatestChat: false,
|
||||
...options,
|
||||
})
|
||||
.then(nextSession => {
|
||||
if (requestSeq !== sessionLoadSeqRef.current) return undefined;
|
||||
setSession(nextSession ?? null);
|
||||
setHasPinned(!!nextSession?.pinned);
|
||||
return nextSession ?? undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
if (creatingSessionRef.current?.promise === promise) {
|
||||
creatingSessionRef.current = null;
|
||||
}
|
||||
});
|
||||
creatingSessionRef.current = { docId: doc.id, promise };
|
||||
return promise;
|
||||
},
|
||||
[doc, session]
|
||||
);
|
||||
|
||||
const updateSession = useCallback(
|
||||
async (options: UpdateChatSessionInput) => {
|
||||
if (!AIProvider.session || !doc) {
|
||||
return undefined;
|
||||
}
|
||||
const requestSeq = ++sessionLoadSeqRef.current;
|
||||
await AIProvider.session.updateSession(options);
|
||||
const nextSession = await AIProvider.session.getSession(
|
||||
doc.workspace.id,
|
||||
options.sessionId
|
||||
);
|
||||
if (requestSeq !== sessionLoadSeqRef.current) return undefined;
|
||||
setSession(nextSession ?? null);
|
||||
setHasPinned(!!nextSession?.pinned);
|
||||
return nextSession ?? undefined;
|
||||
},
|
||||
[doc]
|
||||
);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
if (!canCreateNewSession) {
|
||||
return;
|
||||
}
|
||||
if (doc && creatingFreshSessionRef.current?.docId === doc.id) {
|
||||
return creatingFreshSessionRef.current.promise;
|
||||
}
|
||||
resetPanel();
|
||||
const requestSeq = sessionLoadSeqRef.current;
|
||||
setSession(null);
|
||||
setHasContextMessages(false);
|
||||
|
||||
if (!AIProvider.session || !doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
let promise: Promise<void>;
|
||||
promise = AIProvider.session
|
||||
.createSessionWithHistory({
|
||||
docId: doc.id,
|
||||
workspaceId: doc.workspace.id,
|
||||
promptName: 'Chat With AFFiNE AI',
|
||||
reuseLatestChat: false,
|
||||
})
|
||||
.then(nextSession => {
|
||||
if (requestSeq === sessionLoadSeqRef.current) {
|
||||
setSession(nextSession ?? null);
|
||||
setHasPinned(!!nextSession?.pinned);
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
if (creatingFreshSessionRef.current?.promise === promise) {
|
||||
creatingFreshSessionRef.current = null;
|
||||
}
|
||||
});
|
||||
creatingFreshSessionRef.current = { docId: doc.id, promise };
|
||||
return promise;
|
||||
}, [canCreateNewSession, doc, resetPanel]);
|
||||
|
||||
const openSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (session?.sessionId === sessionId || !AIProvider.session || !doc) {
|
||||
if (session?.sessionId === sessionId || !runtime) {
|
||||
return;
|
||||
}
|
||||
const requestSeq = ++sessionLoadSeqRef.current;
|
||||
try {
|
||||
const nextSession = await AIProvider.session.getSession(
|
||||
doc.workspace.id,
|
||||
sessionId
|
||||
);
|
||||
if (requestSeq !== sessionLoadSeqRef.current) return;
|
||||
if (!nextSession) {
|
||||
// Drop stale tab if session no longer exists.
|
||||
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
|
||||
return;
|
||||
}
|
||||
if (!isSessionAvailableInDocPanel(nextSession, doc.id)) {
|
||||
setOpenTabs([]);
|
||||
workbench.open(`/${nextSession.docId}?sessionId=${sessionId}`, {
|
||||
at: 'active',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSession(nextSession);
|
||||
setHasPinned(!!nextSession.pinned);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
await runtime.dispatch({ type: 'openSession', sessionId });
|
||||
},
|
||||
[doc, session?.sessionId, setOpenTabs, workbench]
|
||||
[runtime, session?.sessionId]
|
||||
);
|
||||
|
||||
const openDoc = useCallback(
|
||||
@@ -361,159 +197,47 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
workbench.open(`/${docId}`, { at: 'active' });
|
||||
return;
|
||||
}
|
||||
setOpenTabs([]);
|
||||
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
|
||||
},
|
||||
[
|
||||
doc,
|
||||
openSession,
|
||||
session?.pinned,
|
||||
session?.sessionId,
|
||||
setOpenTabs,
|
||||
workbench,
|
||||
]
|
||||
);
|
||||
|
||||
const deleteSession = useMemo(
|
||||
() =>
|
||||
createSessionDeleteHandler({
|
||||
t,
|
||||
notificationService,
|
||||
canDeleteSession: () => Boolean(AIProvider.histories),
|
||||
cleanupSession: async sessionToDelete => {
|
||||
await AIProvider.histories?.cleanup(
|
||||
sessionToDelete.workspaceId,
|
||||
sessionToDelete.docId || undefined,
|
||||
[sessionToDelete.sessionId]
|
||||
);
|
||||
},
|
||||
isActiveSession: sessionToDelete =>
|
||||
sessionToDelete.sessionId === session?.sessionId,
|
||||
onActiveSessionDeleted: () => {
|
||||
resetPanel();
|
||||
setSession(null);
|
||||
setHasContextMessages(false);
|
||||
},
|
||||
}),
|
||||
[notificationService, resetPanel, session?.sessionId, t]
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(sessionId: string) => {
|
||||
let fallback: CopilotChatHistoryFragment | undefined;
|
||||
setOpenTabs(prev => {
|
||||
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
|
||||
if (idx === -1) return prev;
|
||||
const next = prev.filter(tab => tab.sessionId !== sessionId);
|
||||
const visibleNext = filterDocPanelTabs(next, doc?.id);
|
||||
fallback = visibleNext[idx] ?? visibleNext[idx - 1] ?? visibleNext[0];
|
||||
return next;
|
||||
});
|
||||
if (session?.sessionId !== sessionId) return;
|
||||
if (fallback) {
|
||||
openSession(fallback.sessionId).catch(console.error);
|
||||
} else {
|
||||
resetPanel();
|
||||
setSession(null);
|
||||
setHasContextMessages(false);
|
||||
}
|
||||
},
|
||||
[doc?.id, openSession, resetPanel, session?.sessionId, setOpenTabs]
|
||||
);
|
||||
|
||||
const togglePin = useCallback(async () => {
|
||||
const pinned = !session?.pinned;
|
||||
setHasPinned(true);
|
||||
if (!session) {
|
||||
await createSession({ pinned });
|
||||
return;
|
||||
}
|
||||
setSession(prev => (prev ? { ...prev, pinned } : prev));
|
||||
await updateSession({
|
||||
sessionId: session.sessionId,
|
||||
pinned,
|
||||
});
|
||||
}, [createSession, session, updateSession]);
|
||||
|
||||
const rebindSession = useCallback(async () => {
|
||||
if (!session || !doc) {
|
||||
return;
|
||||
}
|
||||
if (session.docId !== doc.id) {
|
||||
await updateSession({
|
||||
sessionId: session.sessionId,
|
||||
docId: doc.id,
|
||||
});
|
||||
}
|
||||
}, [doc, session, updateSession]);
|
||||
|
||||
const onEmbeddingProgressChange = useCallback(
|
||||
(count: Record<ContextEmbedStatus, number>) => {
|
||||
const total = count.finished + count.processing + count.failed;
|
||||
setEmbeddingProgress([count.finished, total]);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onContextChange = useCallback(
|
||||
(context: Partial<ChatContextValue>) => {
|
||||
if (context.status) {
|
||||
setStatus(context.status);
|
||||
}
|
||||
if (context.messages) {
|
||||
setHasContextMessages(context.messages.length > 0);
|
||||
}
|
||||
if (context.status === 'success') {
|
||||
rebindSession().catch(console.error);
|
||||
}
|
||||
},
|
||||
[rebindSession]
|
||||
[doc, openSession, session?.pinned, session?.sessionId, workbench]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (session !== undefined) {
|
||||
const navigationRequest = snapshot?.navigationRequest;
|
||||
if (!navigationRequest) {
|
||||
return;
|
||||
}
|
||||
if (chatContent) {
|
||||
chatContent.remove();
|
||||
setChatContent(null);
|
||||
}
|
||||
if (chatToolbar) {
|
||||
chatToolbar.remove();
|
||||
setChatToolbar(null);
|
||||
}
|
||||
if (chatTabs) {
|
||||
chatTabs.remove();
|
||||
setChatTabs(null);
|
||||
}
|
||||
}, [chatContent, chatTabs, chatToolbar, session]);
|
||||
workbench.open(
|
||||
`/${navigationRequest.docId}?sessionId=${navigationRequest.sessionId}`,
|
||||
{ at: 'active' }
|
||||
);
|
||||
}, [snapshot?.navigationRequest, workbench]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.sessionId) return;
|
||||
setOpenTabs(prev => {
|
||||
const existing = prev.findIndex(
|
||||
tab => tab.sessionId === session.sessionId
|
||||
const deleteSession = useCallback(
|
||||
async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||
if (!runtime) return;
|
||||
const confirm = await notificationService.confirm({
|
||||
title: t['com.affine.ai.chat-panel.session.delete.confirm.title'](),
|
||||
message: t['com.affine.ai.chat-panel.session.delete.confirm.message'](),
|
||||
confirmText: t['Delete'](),
|
||||
cancelText: t['Cancel'](),
|
||||
});
|
||||
if (!confirm) return;
|
||||
await runtime.dispatch({
|
||||
type: 'deleteSession',
|
||||
sessionId: sessionToDelete.sessionId,
|
||||
});
|
||||
notificationService.toast(
|
||||
t['com.affine.ai.chat-panel.session.delete.toast.success'](),
|
||||
{}
|
||||
);
|
||||
if (existing !== -1) {
|
||||
if (prev[existing] === session) return prev;
|
||||
const next = prev.slice();
|
||||
next[existing] = session;
|
||||
return next;
|
||||
}
|
||||
return [...prev, session];
|
||||
});
|
||||
}, [session, setOpenTabs]);
|
||||
},
|
||||
[notificationService, runtime, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
Promise.resolve(AIProvider.userInfo)
|
||||
.then(userInfo => {
|
||||
if (!disposed && userIdRef.current === undefined) {
|
||||
userIdRef.current = userInfo?.id ?? null;
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
const subscription = AIProvider.slots.userInfo.subscribe(userInfo => {
|
||||
userIdRef.current ??= AIAppEvents.userInfo.value?.id ?? null;
|
||||
const subscription = AIAppEvents.userInfo.subscribe(userInfo => {
|
||||
const nextUserId = userInfo?.id ?? null;
|
||||
const shouldReset = shouldResetChatPanelOnUserInfoChange({
|
||||
previousUserId: userIdRef.current,
|
||||
@@ -523,220 +247,90 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
if (!shouldReset) {
|
||||
return;
|
||||
}
|
||||
resetPanel();
|
||||
initPanel().catch(console.error);
|
||||
runtime?.dispatch({ type: 'initialize' }).catch(console.error);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [initPanel, resetPanel]);
|
||||
}, [runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
const docId = doc?.id;
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lastDocIdRef.current &&
|
||||
lastDocIdRef.current !== docId &&
|
||||
!session?.pinned
|
||||
) {
|
||||
resetPanel();
|
||||
setHasContextMessages(false);
|
||||
}
|
||||
lastDocIdRef.current = docId;
|
||||
}, [doc?.id, resetPanel, session?.pinned]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || session !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (AIProvider.session) {
|
||||
initPanel().catch(console.error);
|
||||
return;
|
||||
}
|
||||
const subscription = AIProvider.slots.sessionReady.subscribe(ready => {
|
||||
if (!ready || session !== undefined) return;
|
||||
initPanel().catch(console.error);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [doc, initPanel, session]);
|
||||
|
||||
const contentKey = getChatContentKey({
|
||||
docId: doc?.id,
|
||||
hasPinned,
|
||||
isGenerating: status === 'loading' || status === 'transmitting',
|
||||
previousSessionDocId: prevSessionDocIdRef.current,
|
||||
previousSessionId: prevSessionIdRef.current,
|
||||
session,
|
||||
const chatContent = useAIChatElement({
|
||||
containerRef: chatContainerRef,
|
||||
selector: 'ai-chat-content',
|
||||
enabled: isBodyProvided && !!runtime && !!snapshot,
|
||||
createElement: () => new AIChatContent(),
|
||||
configureElement: content => {
|
||||
if (!runtime || !snapshot) return;
|
||||
content.host = host;
|
||||
content.session = session;
|
||||
content.runtime = runtime;
|
||||
content.runtimeSnapshot = snapshot;
|
||||
content.workspaceId = doc.workspace.id;
|
||||
content.docId = doc.id;
|
||||
content.reasoningConfig = reasoningConfig;
|
||||
content.searchMenuConfig = searchMenuConfig;
|
||||
content.docDisplayConfig = docDisplayConfig;
|
||||
content.extensions = specs;
|
||||
content.serverService = framework.get(ServerService);
|
||||
content.affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||
content.affineWorkspaceDialogService = framework.get(
|
||||
WorkspaceDialogService
|
||||
);
|
||||
content.affineThemeService = framework.get(AppThemeService);
|
||||
content.notificationService = notificationService;
|
||||
content.aiDraftService = framework.get(AIDraftService);
|
||||
content.aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
content.peekViewService = framework.get(PeekViewService);
|
||||
content.subscriptionService = framework.get(SubscriptionService);
|
||||
content.aiModelService = framework.get(AIModelService);
|
||||
content.onAISubscribe = handleAISubscribe;
|
||||
content.width = sidebarWidthSignal;
|
||||
content.onOpenDoc = (docId: string, sessionId?: string) => {
|
||||
openDoc(docId, sessionId).catch(console.error);
|
||||
};
|
||||
},
|
||||
onElementReady: content => {
|
||||
onLoad?.(content);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.sessionId) {
|
||||
prevSessionIdRef.current = session.sessionId;
|
||||
prevSessionDocIdRef.current = session.docId ?? doc?.id ?? null;
|
||||
}
|
||||
}, [doc?.id, session?.docId, session?.sessionId]);
|
||||
useAIChatElement({
|
||||
containerRef: chatToolbarContainerRef,
|
||||
selector: 'ai-chat-toolbar',
|
||||
enabled: isHeaderProvided && !!runtime && !!snapshot,
|
||||
createElement: () => new AIChatToolbar(),
|
||||
configureElement: tool => {
|
||||
if (!runtime || !snapshot) return;
|
||||
configureAIChatToolbar(tool, {
|
||||
session,
|
||||
runtime,
|
||||
runtimeSnapshot: snapshot,
|
||||
docId: doc.id,
|
||||
docDisplayConfig,
|
||||
notificationService,
|
||||
onOpenDoc: (docId: string, sessionId: string) => {
|
||||
openDoc(docId, sessionId).catch(console.error);
|
||||
},
|
||||
onSessionDelete: (
|
||||
sessionToDelete: BlockSuitePresets.AIRecentSession
|
||||
) => {
|
||||
deleteSession(sessionToDelete).catch(console.error);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatContent) {
|
||||
contentKeyRef.current = contentKey;
|
||||
return;
|
||||
}
|
||||
if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
|
||||
chatContent.remove();
|
||||
setChatContent(null);
|
||||
}
|
||||
contentKeyRef.current = contentKey;
|
||||
}, [chatContent, contentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBodyProvided || !chatContainerRef.current || !doc || !host) {
|
||||
return;
|
||||
}
|
||||
if (session === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = chatContent;
|
||||
|
||||
if (!content) {
|
||||
content = new AIChatContent();
|
||||
}
|
||||
|
||||
content.host = host;
|
||||
content.session = session;
|
||||
content.createSession = createSession;
|
||||
content.workspaceId = doc.workspace.id;
|
||||
content.docId = doc.id;
|
||||
content.reasoningConfig = reasoningConfig;
|
||||
content.searchMenuConfig = searchMenuConfig;
|
||||
content.docDisplayConfig = docDisplayConfig;
|
||||
content.extensions = specs;
|
||||
content.serverService = framework.get(ServerService);
|
||||
content.affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||
content.affineWorkspaceDialogService = framework.get(
|
||||
WorkspaceDialogService
|
||||
);
|
||||
content.affineThemeService = framework.get(AppThemeService);
|
||||
content.notificationService = notificationService;
|
||||
content.aiDraftService = framework.get(AIDraftService);
|
||||
content.aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
content.peekViewService = framework.get(PeekViewService);
|
||||
content.subscriptionService = framework.get(SubscriptionService);
|
||||
content.aiModelService = framework.get(AIModelService);
|
||||
content.onAISubscribe = handleAISubscribe;
|
||||
content.onEmbeddingProgressChange = onEmbeddingProgressChange;
|
||||
content.onContextChange = onContextChange;
|
||||
content.width = sidebarWidthSignal;
|
||||
content.onOpenDoc = (docId: string, sessionId?: string) => {
|
||||
openDoc(docId, sessionId).catch(console.error);
|
||||
};
|
||||
|
||||
if (!chatContent) {
|
||||
chatContainerRef.current.append(content);
|
||||
setChatContent(content);
|
||||
onLoad?.(content);
|
||||
}
|
||||
}, [
|
||||
chatContent,
|
||||
createSession,
|
||||
doc,
|
||||
docDisplayConfig,
|
||||
framework,
|
||||
handleAISubscribe,
|
||||
host,
|
||||
isBodyProvided,
|
||||
notificationService,
|
||||
onContextChange,
|
||||
onEmbeddingProgressChange,
|
||||
onLoad,
|
||||
openDoc,
|
||||
reasoningConfig,
|
||||
searchMenuConfig,
|
||||
session,
|
||||
sidebarWidthSignal,
|
||||
specs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHeaderProvided || !chatToolbarContainerRef.current || !doc) {
|
||||
return;
|
||||
}
|
||||
if (session === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = getOrCreateAIChatToolbar(chatToolbar);
|
||||
configureAIChatToolbar(tool, {
|
||||
session,
|
||||
workspaceId: doc.workspace.id,
|
||||
docId: doc.id,
|
||||
status,
|
||||
canCreateNewSession,
|
||||
docDisplayConfig,
|
||||
notificationService,
|
||||
onNewSession: () => {
|
||||
newSession().catch(console.error);
|
||||
},
|
||||
onTogglePin: togglePin,
|
||||
onOpenSession: (sessionId: string) => {
|
||||
openSession(sessionId).catch(console.error);
|
||||
},
|
||||
onOpenDoc: (docId: string, sessionId: string) => {
|
||||
openDoc(docId, sessionId).catch(console.error);
|
||||
},
|
||||
onSessionDelete: (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||
deleteSession(sessionToDelete).catch(console.error);
|
||||
},
|
||||
});
|
||||
|
||||
if (!chatToolbar) {
|
||||
chatToolbarContainerRef.current.append(tool);
|
||||
setChatToolbar(tool);
|
||||
}
|
||||
}, [
|
||||
chatToolbar,
|
||||
canCreateNewSession,
|
||||
deleteSession,
|
||||
doc,
|
||||
docDisplayConfig,
|
||||
isHeaderProvided,
|
||||
newSession,
|
||||
notificationService,
|
||||
openDoc,
|
||||
openSession,
|
||||
session,
|
||||
status,
|
||||
togglePin,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatTabsContainerRef.current || !doc) {
|
||||
return;
|
||||
}
|
||||
if (session === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tabs = chatTabs;
|
||||
if (!tabs) {
|
||||
tabs = new AIChatTabs();
|
||||
chatTabsContainerRef.current.append(tabs);
|
||||
setChatTabs(tabs);
|
||||
}
|
||||
tabs.sessions = visibleOpenTabs;
|
||||
tabs.activeSessionId = session?.sessionId;
|
||||
tabs.showDraftTab =
|
||||
visibleOpenTabs.length === 0 && !session?.sessionId && !!doc;
|
||||
tabs.onSelectTab = (sessionId: string) => {
|
||||
openSession(sessionId).catch(console.error);
|
||||
};
|
||||
tabs.onCloseTab = (sessionId: string) => {
|
||||
closeTab(sessionId);
|
||||
};
|
||||
}, [chatTabs, closeTab, doc, openSession, session, visibleOpenTabs]);
|
||||
useAIChatElement({
|
||||
containerRef: chatTabsContainerRef,
|
||||
selector: 'ai-chat-tabs',
|
||||
enabled: !!runtime && !!snapshot,
|
||||
createElement: () => new AIChatTabs(),
|
||||
configureElement: tabs => {
|
||||
if (!runtime || !snapshot) return;
|
||||
tabs.runtime = runtime;
|
||||
tabs.runtimeSnapshot = snapshot;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor?.host || !chatContent) {
|
||||
@@ -766,19 +360,17 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
if (autoResized) {
|
||||
return;
|
||||
}
|
||||
const subscription = AIProvider.slots.previewPanelOpenChange.subscribe(
|
||||
open => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const sidebarWidth = workbench.sidebarWidth$.value;
|
||||
const minSidebarWidth = 1080;
|
||||
if (!sidebarWidth || sidebarWidth < minSidebarWidth) {
|
||||
workbench.setSidebarWidth(minSidebarWidth);
|
||||
setAutoResized(true);
|
||||
}
|
||||
const subscription = AIAppEvents.previewPanelOpenChange.subscribe(open => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
const sidebarWidth = workbench.sidebarWidth$.value;
|
||||
const minSidebarWidth = 1080;
|
||||
if (!sidebarWidth || sidebarWidth < minSidebarWidth) {
|
||||
workbench.setSidebarWidth(minSidebarWidth);
|
||||
setAutoResized(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
@@ -843,14 +435,16 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
||||
chatTabsContainerRef.current = node;
|
||||
}, []);
|
||||
|
||||
const isEmbedding =
|
||||
embeddingProgress[1] > 0 && embeddingProgress[0] < embeddingProgress[1];
|
||||
const [done, total] = embeddingProgress;
|
||||
const isInitialized = session !== undefined;
|
||||
const embeddingCount = snapshot?.composer.context.embeddingCount;
|
||||
const done = embeddingCount?.finished ?? 0;
|
||||
const total =
|
||||
done + (embeddingCount?.processing ?? 0) + (embeddingCount?.failed ?? 0);
|
||||
const isEmbedding = total > 0 && done < total;
|
||||
const hasRuntimeSnapshot = !!snapshot;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{!isInitialized ? (
|
||||
{!hasRuntimeSnapshot ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.loading}>
|
||||
<Logo1Icon className={styles.loadingIcon} />
|
||||
|
||||
Reference in New Issue
Block a user