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:
DarkSky
2026-05-13 21:57:50 +08:00
committed by GitHub
parent 322f2ba986
commit e222f06e94
64 changed files with 5348 additions and 4510 deletions
@@ -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>
)}
@@ -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();
});
});
@@ -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} />