diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts index 4f787f7ca5..2f25a215ec 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts @@ -150,6 +150,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor getSessionId!: () => Promise; + @property({ attribute: false }) + accessor createSessionId!: () => Promise; + @property({ attribute: false }) accessor updateContext!: (context: Partial) => void; @@ -347,7 +350,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { retry = async () => { const { doc } = this.host; try { - const sessionId = await this.getSessionId(); + const sessionId = await this.createSessionId(); if (!sessionId) return; const abortController = new AbortController(); diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts index 6926832e3c..cdf02cbc5a 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -1,18 +1,12 @@ import './chat-panel-messages'; -import type { - ContextEmbedStatus, - CopilotContextDoc, - CopilotContextFile, - CopilotDocType, -} from '@affine/graphql'; +import type { ContextEmbedStatus } from '@affine/graphql'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; -import { NotificationProvider } from '@blocksuite/affine/shared/services'; import type { SpecBuilder } from '@blocksuite/affine/shared/utils'; import type { EditorHost } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std'; import type { Store } from '@blocksuite/affine/store'; -import { HelpIcon, InformationIcon } from '@blocksuite/icons/lit'; +import { HelpIcon } from '@blocksuite/icons/lit'; import { type Signal, signal } from '@preact/signals-core'; import { css, html, type PropertyValues } from 'lit'; import { property, state } from 'lit/decorators.js'; @@ -21,18 +15,8 @@ import { styleMap } from 'lit/directives/style-map.js'; import { throttle } from 'lodash-es'; import type { - ChatChip, - CollectionChip, - DocChip, DocDisplayConfig, - FileChip, SearchMenuConfig, - TagChip, -} from '../components/ai-chat-chips'; -import { - isCollectionChip, - isDocChip, - isTagChip, } from '../components/ai-chat-chips'; import type { AINetworkSearchConfig } from '../components/ai-chat-input'; import { type HistoryMessage } from '../components/ai-chat-messages'; @@ -125,20 +109,9 @@ export class ChatPanel extends SignalWatcher( .chat-panel-hints :nth-child(2) { color: var(--affine-text-secondary-color); } - - .chat-panel-footer { - margin: 8px 0px; - height: 20px; - display: flex; - gap: 4px; - align-items: center; - color: var(--affine-text-secondary-color); - font-size: 12px; - user-select: none; - } `; - private readonly _chatMessages: Ref = + private readonly _chatMessagesRef: Ref = createRef(); // request counter to track the latest request @@ -163,9 +136,8 @@ export class ChatPanel extends SignalWatcher( const messages: HistoryMessage[] = actions ? [...actions] : []; - const history = histories?.find( - history => history.sessionId === this._chatSessionId - ); + const sessionId = await this._getSessionId(); + const history = histories?.find(history => history.sessionId === sessionId); if (history) { messages.push(...history.messages); AIProvider.LAST_ROOT_SESSION_ID = history.sessionId; @@ -182,98 +154,38 @@ export class ChatPanel extends SignalWatcher( this._scrollToEnd(); }; - private readonly _initChips = async () => { - // context not initialized - if (!this._chatSessionId || !this._chatContextId) { - return; - } - - // context initialized, show the chips - const { - docs = [], - files = [], - tags = [], - collections = [], - } = (await AIProvider.context?.getContextDocsAndFiles( - this.doc.workspace.id, - this._chatSessionId, - this._chatContextId - )) || {}; - - const docChips: DocChip[] = docs.map(doc => ({ - docId: doc.id, - state: doc.status || 'processing', - tooltip: doc.error, - createdAt: doc.createdAt, - })); - - const fileChips: FileChip[] = await Promise.all( - files.map(async file => { - const blob = await this.host.doc.blobSync.get(file.blobId); - return { - file: new File(blob ? [blob] : [], file.name), - blobId: file.blobId, - fileId: file.id, - state: blob ? file.status : 'failed', - tooltip: blob ? file.error : 'File not found in blob storage', - createdAt: file.createdAt, - }; - }) - ); - - const tagChips: TagChip[] = tags.map(tag => ({ - tagId: tag.id, - state: 'finished', - createdAt: tag.createdAt, - })); - - const collectionChips: CollectionChip[] = collections.map(collection => ({ - collectionId: collection.id, - state: 'finished', - createdAt: collection.createdAt, - })); - - const chips: ChatChip[] = [ - ...docChips, - ...fileChips, - ...tagChips, - ...collectionChips, - ].sort((a, b) => { - const aTime = a.createdAt ?? Date.now(); - const bTime = b.createdAt ?? Date.now(); - return aTime - bTime; - }); - - this.updateChips(chips); - }; - - private readonly _initEmbeddingProgress = async () => { - await this._pollContextDocsAndFiles(); + private readonly _updateEmbeddingProgress = ( + count: Record + ) => { + const total = count.finished + count.processing + count.failed; + this.embeddingProgress = [count.finished, total]; }; private readonly _getSessionId = async () => { - if (this._chatSessionId) { - return this._chatSessionId; + if (this._sessionId) { + return this._sessionId; } - this._chatSessionId = await AIProvider.session?.createSession( + const sessions = ( + (await AIProvider.session?.getSessions( + this.doc.workspace.id, + this.doc.id, + { action: false } + )) || [] + ).filter(session => !session.parentSessionId); + const sessionId = sessions.at(-1)?.id; + this._sessionId = sessionId; + return this._sessionId; + }; + + private readonly _createSessionId = async () => { + if (this._sessionId) { + return this._sessionId; + } + this._sessionId = await AIProvider.session?.createSession( this.doc.workspace.id, this.doc.id ); - return this._chatSessionId; - }; - - private readonly _getContextId = async () => { - if (this._chatContextId) { - return this._chatContextId; - } - const sessionId = await this._getSessionId(); - if (sessionId) { - this._chatContextId = await AIProvider.context?.createContext( - this.doc.workspace.id, - sessionId - ); - } - return this._chatContextId; + return this._sessionId; }; @property({ attribute: false }) @@ -298,192 +210,55 @@ export class ChatPanel extends SignalWatcher( accessor previewSpecBuilder!: SpecBuilder; @state() - accessor isLoading = true; + accessor isLoading = false; @state() accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE; - @state() - accessor chips: ChatChip[] = []; - @state() accessor embeddingProgress: [number, number] = [0, 0]; - private _chatSessionId: string | null | undefined = null; + private _isInitialized = false; - private _chatContextId: string | null | undefined = null; + // always use getSessionId to get the sessionId + private _sessionId: string | undefined = undefined; - private _isOpen: Signal = signal(false); + private _isSidebarOpen: Signal = signal(false); - private _width: Signal = signal(undefined); - - private _pollAbortController: AbortController | null = null; + private _sidebarWidth: Signal = signal(undefined); private readonly _scrollToEnd = () => { if (!this._wheelTriggered) { - this._chatMessages.value?.scrollToEnd(); + this._chatMessagesRef.value?.scrollToEnd(); } }; private readonly _throttledScrollToEnd = throttle(this._scrollToEnd, 600); - private readonly _cleanupHistories = async () => { - const notification = this.host.std.getOptional(NotificationProvider); - if (!notification) return; - try { - if ( - await notification.confirm({ - title: 'Clear History', - message: - 'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.', - confirmText: 'Confirm', - cancelText: 'Cancel', - }) - ) { - const actionIds = this.chatContextValue.messages - .filter(item => 'sessionId' in item) - .map(item => item.sessionId); - await AIProvider.histories?.cleanup( - this.doc.workspace.id, - this.doc.id, - [ - ...(this._chatSessionId ? [this._chatSessionId] : []), - ...(actionIds || []), - ] - ); - notification.toast('History cleared'); - await this._updateHistory(); - } - } catch { - notification.toast('Failed to clear history'); - } - }; - private readonly _initPanel = async () => { try { - if (!this._isOpen.value) return; - + if (!this._isSidebarOpen.value) return; + if (this.isLoading) return; const userId = (await AIProvider.userInfo)?.id; if (!userId) return; this.isLoading = true; - const sessions = ( - (await AIProvider.session?.getSessions( - this.doc.workspace.id, - this.doc.id, - { action: false } - )) || [] - ).filter(session => !session.parentSessionId); - - if (sessions && sessions.length) { - this._chatSessionId = sessions.at(-1)?.id; - await this._updateHistory(); - } + await this._updateHistory(); this.isLoading = false; - if (this._chatSessionId) { - this._chatContextId = await AIProvider.context?.getContextId( - this.doc.workspace.id, - this._chatSessionId - ); - } - await this._initChips(); - await this._initEmbeddingProgress(); + this._isInitialized = true; } catch (error) { console.error(error); } }; private readonly _resetPanel = () => { - this._abortPoll(); - this._chatSessionId = null; - this._chatContextId = null; + this._sessionId = undefined; this.chatContextValue = DEFAULT_CHAT_CONTEXT_VALUE; - this.isLoading = true; - this.chips = []; + this.isLoading = false; + this._isInitialized = false; this.embeddingProgress = [0, 0]; }; - private readonly _pollContextDocsAndFiles = async () => { - if (!this._chatSessionId || !this._chatContextId || !AIProvider.context) { - return; - } - if (this._pollAbortController) { - // already polling, reset timer - this._abortPoll(); - } - this._pollAbortController = new AbortController(); - await AIProvider.context.pollContextDocsAndFiles( - this.doc.workspace.id, - this._chatSessionId, - this._chatContextId, - this._onPoll, - this._pollAbortController.signal - ); - }; - - private readonly _onPoll = ( - result?: BlockSuitePresets.AIDocsAndFilesContext - ) => { - if (!result) { - this._abortPoll(); - return; - } - const { - docs: sDocs = [], - files = [], - tags = [], - collections = [], - } = result; - const docs = [ - ...sDocs, - ...tags.flatMap(tag => tag.docs), - ...collections.flatMap(collection => collection.docs), - ]; - const hashMap = new Map< - string, - CopilotContextDoc | CopilotDocType | CopilotContextFile - >(); - const count: Record = { - finished: 0, - processing: 0, - failed: 0, - }; - docs.forEach(doc => { - hashMap.set(doc.id, doc); - doc.status && count[doc.status]++; - }); - files.forEach(file => { - hashMap.set(file.id, file); - file.status && count[file.status]++; - }); - const nextChips = this.chips.map(chip => { - if (isTagChip(chip) || isCollectionChip(chip)) { - return chip; - } - const id = isDocChip(chip) ? chip.docId : chip.fileId; - const item = id && hashMap.get(id); - if (item && item.status) { - return { - ...chip, - state: item.status, - tooltip: 'error' in item ? item.error : undefined, - }; - } - return chip; - }); - const total = count.finished + count.processing + count.failed; - this.embeddingProgress = [count.finished, total]; - this.updateChips(nextChips); - if (count.processing === 0) { - this._abortPoll(); - } - }; - - private readonly _abortPoll = () => { - this._pollAbortController?.abort(); - this._pollAbortController = null; - }; - protected override updated(_changedProperties: PropertyValues) { if (_changedProperties.has('doc')) { this._resetPanel(); @@ -515,7 +290,7 @@ export class ChatPanel extends SignalWatcher( } protected override firstUpdated(): void { - const chatMessages = this._chatMessages.value; + const chatMessages = this._chatMessagesRef.value; if (chatMessages) { chatMessages.updateComplete .then(() => { @@ -561,16 +336,16 @@ export class ChatPanel extends SignalWatcher( ); const isOpen = this.appSidebarConfig.isOpen(); - this._isOpen = isOpen.signal; + this._isSidebarOpen = isOpen.signal; this._disposables.add(isOpen.cleanup); const width = this.appSidebarConfig.getWidth(); - this._width = width.signal; + this._sidebarWidth = width.signal; this._disposables.add(width.cleanup); this._disposables.add( - this._isOpen.subscribe(isOpen => { - if (isOpen && this.isLoading) { + this._isSidebarOpen.subscribe(isOpen => { + if (isOpen && !this._isInitialized) { this._initPanel().catch(console.error); } }) @@ -581,10 +356,6 @@ export class ChatPanel extends SignalWatcher( this.chatContextValue = { ...this.chatContextValue, ...context }; }; - updateChips = (chips: ChatChip[]) => { - this.chips = chips; - }; - continueInChat = async () => { const text = await getSelectedTextContent(this.host, 'plain-text'); const markdown = await getSelectedTextContent(this.host, 'markdown'); @@ -597,7 +368,7 @@ export class ChatPanel extends SignalWatcher( }; override render() { - const width = this._width.value || 0; + const width = this._sidebarWidth.value || 0; const style = styleMap({ padding: width > 540 ? '8px 24px 0 24px' : '8px 12px 0 12px', }); @@ -622,38 +393,34 @@ export class ChatPanel extends SignalWatcher( - - - + .searchMenuConfig=${this.searchMenuConfig} + .trackOptions=${{ + where: 'chat-panel', + control: 'chat-send', + }} + > `; } } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts index 2bddd6f188..9361320769 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts @@ -86,7 +86,7 @@ export class ChatPanelChips extends SignalWatcher( accessor chips!: ChatChip[]; @property({ attribute: false }) - accessor getContextId!: () => Promise; + accessor createContextId!: () => Promise; @property({ attribute: false }) accessor updateChips!: (chips: ChatChip[]) => void; @@ -378,7 +378,7 @@ export class ChatPanelChips extends SignalWatcher( private readonly _addDocToContext = async (chip: DocChip) => { try { - const contextId = await this.getContextId(); + const contextId = await this.createContextId(); if (!contextId || !AIProvider.context) { throw new Error('Context not found'); } @@ -396,7 +396,7 @@ export class ChatPanelChips extends SignalWatcher( private readonly _addFileToContext = async (chip: FileChip) => { try { - const contextId = await this.getContextId(); + const contextId = await this.createContextId(); if (!contextId || !AIProvider.context) { throw new Error('Context not found'); } @@ -420,7 +420,7 @@ export class ChatPanelChips extends SignalWatcher( private readonly _addTagToContext = async (chip: TagChip) => { try { - const contextId = await this.getContextId(); + const contextId = await this.createContextId(); if (!contextId || !AIProvider.context) { throw new Error('Context not found'); } @@ -444,7 +444,7 @@ export class ChatPanelChips extends SignalWatcher( private readonly _addCollectionToContext = async (chip: CollectionChip) => { try { - const contextId = await this.getContextId(); + const contextId = await this.createContextId(); if (!contextId || !AIProvider.context) { throw new Error('Context not found'); } @@ -474,7 +474,7 @@ export class ChatPanelChips extends SignalWatcher( chip: ChatChip ): Promise => { try { - const contextId = await this.getContextId(); + const contextId = await this.createContextId(); if (!contextId || !AIProvider.context) { return true; } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts new file mode 100644 index 0000000000..ff8c2cd62d --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts @@ -0,0 +1,409 @@ +import type { + ContextEmbedStatus, + CopilotContextDoc, + CopilotContextFile, + CopilotDocType, +} from '@affine/graphql'; +import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; +import { NotificationProvider } from '@blocksuite/affine/shared/services'; +import type { EditorHost } from '@blocksuite/affine/std'; +import { ShadowlessElement } from '@blocksuite/affine/std'; +import type { Store } from '@blocksuite/affine/store'; +import { InformationIcon } from '@blocksuite/icons/lit'; +import { type Signal, signal } from '@preact/signals-core'; +import { css, html, type PropertyValues } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import { AIProvider } from '../../provider'; +import type { + ChatChip, + CollectionChip, + DocChip, + DocDisplayConfig, + FileChip, + SearchMenuConfig, + TagChip, +} from '../ai-chat-chips'; +import { isCollectionChip, isDocChip, isTagChip } from '../ai-chat-chips'; +import type { + AIChatInputContext, + AINetworkSearchConfig, +} from '../ai-chat-input'; + +export class AIChatComposer extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .chat-panel-footer { + margin: 8px 0px; + height: 20px; + display: flex; + gap: 4px; + align-items: center; + color: var(--affine-text-secondary-color); + font-size: 12px; + user-select: none; + } + `; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor doc!: Store; + + @property({ attribute: false }) + accessor getSessionId!: () => Promise; + + @property({ attribute: false }) + accessor createSessionId!: () => Promise; + + @property({ attribute: false }) + accessor createChatSessionId!: () => Promise; + + @property({ attribute: false }) + accessor chatContextValue!: AIChatInputContext; + + @property({ attribute: false }) + accessor updateContext!: (context: Partial) => void; + + @property({ attribute: false }) + accessor onHistoryCleared: (() => void) | undefined; + + @property({ attribute: false }) + accessor isVisible: Signal = signal(false); + + @property({ attribute: false }) + accessor updateEmbeddingProgress!: ( + count: Record + ) => void; + + @property({ attribute: false }) + accessor docDisplayConfig!: DocDisplayConfig; + + @property({ attribute: false }) + accessor networkSearchConfig!: AINetworkSearchConfig; + + @property({ attribute: false }) + accessor searchMenuConfig!: SearchMenuConfig; + + @property({ attribute: false }) + accessor onChatSuccess: (() => void) | undefined; + + @property({ attribute: false }) + accessor trackOptions!: BlockSuitePresets.TrackerOptions; + + @property({ attribute: false }) + accessor portalContainer: HTMLElement | null = null; + + @state() + accessor chips: ChatChip[] = []; + + private _isInitialized = false; + + private _isLoading = false; + + private _contextId: string | undefined = undefined; + + private _pollAbortController: AbortController | null = null; + + override render() { + return html` + + + + `; + } + + override connectedCallback() { + super.connectedCallback(); + if (!this.doc) throw new Error('doc is required'); + + this._disposables.add( + AIProvider.slots.userInfo.subscribe(() => { + this._initComposer().catch(console.error); + }) + ); + + this._disposables.add( + this.isVisible.subscribe(isVisible => { + if (isVisible && !this._isInitialized) { + this._initComposer().catch(console.error); + } + }) + ); + } + + protected override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('doc')) { + this._resetComposer(); + requestAnimationFrame(async () => { + await this._initComposer(); + }); + } + } + + private readonly _getContextId = async () => { + if (this._contextId) { + return this._contextId; + } + + const sessionId = await this.getSessionId(); + if (!sessionId) return; + + const contextId = await AIProvider.context?.getContextId( + this.doc.workspace.id, + sessionId + ); + this._contextId = contextId; + return this._contextId; + }; + + private readonly _createContextId = async () => { + if (this._contextId) { + return this._contextId; + } + + const sessionId = await this.createSessionId(); + if (!sessionId) return; + + this._contextId = await AIProvider.context?.createContext( + this.doc.workspace.id, + sessionId + ); + return this._contextId; + }; + + private readonly _initChips = async () => { + // context not initialized + const sessionId = await this.getSessionId(); + const contextId = await this._getContextId(); + if (!sessionId || !contextId) { + return; + } + + // context initialized, show the chips + const { + docs = [], + files = [], + tags = [], + collections = [], + } = (await AIProvider.context?.getContextDocsAndFiles( + this.doc.workspace.id, + sessionId, + contextId + )) || {}; + + const docChips: DocChip[] = docs.map(doc => ({ + docId: doc.id, + state: doc.status || 'processing', + tooltip: doc.error, + createdAt: doc.createdAt, + })); + + const fileChips: FileChip[] = await Promise.all( + files.map(async file => { + const blob = await this.host.doc.blobSync.get(file.blobId); + return { + file: new File(blob ? [blob] : [], file.name), + blobId: file.blobId, + fileId: file.id, + state: blob ? file.status : 'failed', + tooltip: blob ? file.error : 'File not found in blob storage', + createdAt: file.createdAt, + }; + }) + ); + + const tagChips: TagChip[] = tags.map(tag => ({ + tagId: tag.id, + state: 'finished', + createdAt: tag.createdAt, + })); + + const collectionChips: CollectionChip[] = collections.map(collection => ({ + collectionId: collection.id, + state: 'finished', + createdAt: collection.createdAt, + })); + + const chips: ChatChip[] = [ + ...docChips, + ...fileChips, + ...tagChips, + ...collectionChips, + ].sort((a, b) => { + const aTime = a.createdAt ?? Date.now(); + const bTime = b.createdAt ?? Date.now(); + return aTime - bTime; + }); + + this.updateChips(chips); + }; + + private readonly updateChips = (chips: ChatChip[]) => { + this.chips = chips; + }; + + private readonly _pollContextDocsAndFiles = async () => { + const sessionId = await this.getSessionId(); + const contextId = await this._getContextId(); + if (!sessionId || !contextId || !AIProvider.context) { + return; + } + if (this._pollAbortController) { + // already polling, reset timer + this._abortPoll(); + } + this._pollAbortController = new AbortController(); + await AIProvider.context.pollContextDocsAndFiles( + this.doc.workspace.id, + sessionId, + contextId, + this._onPoll, + this._pollAbortController.signal + ); + }; + + private readonly _onPoll = ( + result?: BlockSuitePresets.AIDocsAndFilesContext + ) => { + if (!result) { + this._abortPoll(); + return; + } + const { + docs: sDocs = [], + files = [], + tags = [], + collections = [], + } = result; + const docs = [ + ...sDocs, + ...tags.flatMap(tag => tag.docs), + ...collections.flatMap(collection => collection.docs), + ]; + const hashMap = new Map< + string, + CopilotContextDoc | CopilotDocType | CopilotContextFile + >(); + const count: Record = { + finished: 0, + processing: 0, + failed: 0, + }; + docs.forEach(doc => { + hashMap.set(doc.id, doc); + doc.status && count[doc.status]++; + }); + files.forEach(file => { + hashMap.set(file.id, file); + file.status && count[file.status]++; + }); + const nextChips = this.chips.map(chip => { + if (isTagChip(chip) || isCollectionChip(chip)) { + return chip; + } + const id = isDocChip(chip) ? chip.docId : chip.fileId; + const item = id && hashMap.get(id); + if (item && item.status) { + return { + ...chip, + state: item.status, + tooltip: 'error' in item ? item.error : undefined, + }; + } + return chip; + }); + this.updateChips(nextChips); + this.updateEmbeddingProgress(count); + if (count.processing === 0) { + this._abortPoll(); + } + }; + + private readonly _abortPoll = () => { + this._pollAbortController?.abort(); + this._pollAbortController = null; + }; + + private readonly _cleanupHistories = async () => { + const sessionId = await this.getSessionId(); + const notification = this.host.std.getOptional(NotificationProvider); + if (!notification) return; + try { + if ( + await notification.confirm({ + title: 'Clear History', + message: + 'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.', + confirmText: 'Confirm', + cancelText: 'Cancel', + }) + ) { + const actionIds = this.chatContextValue.messages + .filter(item => 'sessionId' in item) + .map(item => item.sessionId); + await AIProvider.histories?.cleanup( + this.doc.workspace.id, + this.doc.id, + [...(sessionId ? [sessionId] : []), ...(actionIds || [])] + ); + notification.toast('History cleared'); + this.onHistoryCleared?.(); + } + } catch { + notification.toast('Failed to clear history'); + } + }; + + private readonly _initComposer = async () => { + if (!this.isVisible.value) return; + if (this._isLoading) return; + + const userId = (await AIProvider.userInfo)?.id; + if (!userId) return; + + this._isLoading = true; + await this._initChips(); + const isProcessing = this.chips.some(chip => chip.state === 'processing'); + if (isProcessing) { + await this._pollContextDocsAndFiles(); + } + this._isLoading = false; + this._isInitialized = true; + }; + + private readonly _resetComposer = () => { + this._abortPoll(); + this.chips = []; + this._contextId = undefined; + this._isLoading = false; + this._isInitialized = false; + }; +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/index.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/index.ts new file mode 100644 index 0000000000..17f58e50a0 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/index.ts @@ -0,0 +1 @@ +export * from './ai-chat-composer'; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts index d09a90012d..3ea8407a4b 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts @@ -222,6 +222,9 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) { @property({ attribute: false }) accessor getSessionId!: () => Promise; + @property({ attribute: false }) + accessor createSessionId!: () => Promise; + @property({ attribute: false }) accessor getContextId!: () => Promise; @@ -241,13 +244,10 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) { accessor isRootSession: boolean = true; @property({ attribute: false }) - accessor onChatSuccess = () => null; + accessor onChatSuccess: (() => void) | undefined; @property({ attribute: false }) - accessor trackOptions: BlockSuitePresets.TrackerOptions = { - control: 'chat-send', - where: 'chat-panel', - }; + accessor trackOptions!: BlockSuitePresets.TrackerOptions; @property({ attribute: 'data-testid', reflect: true }) accessor testId = 'chat-panel-input-container'; @@ -284,7 +284,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) { } private async _updatePromptName(promptName: string) { - const sessionId = await this.getSessionId(); + const sessionId = await this.createSessionId(); if (sessionId && AIProvider.session) { await AIProvider.session.updateSession(sessionId, promptName); } @@ -542,7 +542,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) { // otherwise, the unauthorized error can not be rendered properly await this._updatePromptName(promptName); - const sessionId = await this.getSessionId(); + const sessionId = await this.createSessionId(); const contexts = await this._getMatchedContexts(userInput); if (abortController.signal.aborted) { return; @@ -570,7 +570,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) { } this.updateContext({ status: 'success' }); - this.onChatSuccess(); + this.onChatSuccess?.(); // update message id from server await this._postUpdateMessages(); } catch (error) { diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index d1234eeec5..f4bcd9298b 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -39,6 +39,7 @@ import { ChatPanelCollectionChip } from './components/ai-chat-chips/collection-c import { ChatPanelDocChip } from './components/ai-chat-chips/doc-chip'; import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip'; import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip'; +import { AIChatComposer } from './components/ai-chat-composer'; import { AIChatInput } from './components/ai-chat-input/ai-chat-input'; import { effects as componentAiItemEffects } from './components/ai-item'; import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer'; @@ -98,6 +99,7 @@ export function registerAIEffects() { customElements.define('chat-panel-messages', ChatPanelMessages); customElements.define('chat-panel', ChatPanel); customElements.define('ai-chat-input', AIChatInput); + customElements.define('ai-chat-composer', AIChatComposer); customElements.define('chat-panel-chips', ChatPanelChips); customElements.define('chat-panel-add-popover', ChatPanelAddPopover); customElements.define( diff --git a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts index 973dd630d9..7395cfcd42 100644 --- a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts +++ b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts @@ -1,3 +1,4 @@ +import type { ContextEmbedStatus } from '@affine/graphql'; import { CanvasElementType, EdgelessCRUDIdentifier, @@ -6,12 +7,11 @@ import { import { ConnectorMode } from '@blocksuite/affine/model'; import { DocModeProvider, - NotificationProvider, TelemetryProvider, } from '@blocksuite/affine/shared/services'; -import type { SpecBuilder } from '@blocksuite/affine/shared/utils'; +import type { Signal, SpecBuilder } from '@blocksuite/affine/shared/utils'; import type { EditorHost } from '@blocksuite/affine/std'; -import { InformationIcon } from '@blocksuite/icons/lit'; +import { signal } from '@preact/signals-core'; import { html, LitElement, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -24,7 +24,6 @@ import { } from '../_common/chat-actions-handle'; import { type AIChatBlockModel } from '../blocks'; import type { - ChatChip, DocDisplayConfig, SearchMenuConfig, } from '../components/ai-chat-chips'; @@ -45,33 +44,33 @@ export class AIChatBlockPeekView extends LitElement { return this.host.std.get(DocModeProvider); } - private get parentSessionId() { - return this.parentModel.props.sessionId; + private get _sessionId() { + return this.blockModel.props.sessionId; } private get historyMessagesString() { - return this.parentModel.props.messages; + return this.blockModel.props.messages; } - private get parentChatBlockId() { - return this.parentModel.id; + private get blockId() { + return this.blockModel.id; } - private get parentRootDocId() { - return this.parentModel.props.rootDocId; + private get rootDocId() { + return this.blockModel.props.rootDocId; } - private get parentRootWorkspaceId() { - return this.parentModel.props.rootWorkspaceId; + private get rootWorkspaceId() { + return this.blockModel.props.rootWorkspaceId; } private _textRendererOptions: TextRendererOptions = {}; - private _chatSessionId: string | null | undefined = null; + private _forkBlockId: string | undefined = undefined; - private _chatContextId: string | null | undefined = null; + private _forkSessionId: string | undefined = undefined; - private _chatBlockId: string | null | undefined = null; + accessor isComposerVisible: Signal = signal(true); private readonly _deserializeHistoryChatMessages = ( historyMessagesString: string @@ -137,45 +136,40 @@ export class AIChatBlockPeekView extends LitElement { abortController: null, messages: [], }); - this._chatSessionId = null; - this._chatContextId = null; - this._chatBlockId = null; + this._forkBlockId = undefined; + this._forkSessionId = undefined; }; private readonly _getSessionId = async () => { - // If has not forked a chat session, fork a new one - if (!this._chatSessionId) { - const latestMessage = this._historyMessages.at(-1); - if (!latestMessage) return; - - const forkSessionId = await AIProvider.forkChat?.({ - workspaceId: this.host.doc.workspace.id, - docId: this.host.doc.id, - sessionId: this.parentSessionId, - latestMessageId: latestMessage.id, - }); - this._chatSessionId = forkSessionId; - } - return this._chatSessionId; + return this._sessionId; }; - private readonly _getContextId = async () => { - if (this._chatContextId) { - return this._chatContextId; + private readonly _createSessionId = async () => { + return this._sessionId; + }; + + private readonly _createForkSessionId = async () => { + if (this._forkSessionId) { + return this._forkSessionId; } - const sessionId = await this._getSessionId(); - if (sessionId) { - this._chatContextId = await AIProvider.context?.createContext( - this.host.doc.workspace.id, - sessionId - ); - } - return this._chatContextId; + + const lastMessage = this._historyMessages.at(-1); + if (!lastMessage) return; + + const { doc } = this.host; + const forkSessionId = await AIProvider.forkChat?.({ + workspaceId: doc.workspace.id, + docId: doc.id, + sessionId: this._sessionId, + latestMessageId: lastMessage.id, + }); + this._forkSessionId = forkSessionId; + return this._forkSessionId; }; private readonly _onChatSuccess = async () => { - if (!this._chatBlockId) { - await this.createAIChatBlock(); + if (!this._forkBlockId) { + await this._createForkChatBlock(); } // Update new chat block messages if there are contents returned from AI await this.updateChatBlockMessages(); @@ -184,7 +178,7 @@ export class AIChatBlockPeekView extends LitElement { /** * Create a new AI chat block based on the current session and history messages */ - createAIChatBlock = async () => { + private readonly _createForkChatBlock = async () => { // Only create AI chat block in edgeless mode const mode = this._modeService.getEditorMode(); if (mode !== 'edgeless') { @@ -192,12 +186,12 @@ export class AIChatBlockPeekView extends LitElement { } // If there is already a chat block, do not create a new one - if (this._chatBlockId) { + if (this._forkBlockId) { return; } // If there is no session id or chat messages, do not create a new chat block - if (!this._chatSessionId || !this.chatContext.messages.length) { + if (!this._forkSessionId || !this.chatContext.messages.length) { return; } @@ -211,43 +205,42 @@ export class AIChatBlockPeekView extends LitElement { } // Get fork session messages - const { parentRootWorkspaceId, parentRootDocId } = this; + const { rootWorkspaceId, rootDocId } = this; const messages = await this._constructBranchChatBlockMessages( - parentRootWorkspaceId, - parentRootDocId, - this._chatSessionId + rootWorkspaceId, + rootDocId, + this._forkSessionId ); if (!messages.length) { return; } - const bound = calcChildBound(this.parentModel, this.host.std); + const bound = calcChildBound(this.blockModel, this.host.std); const crud = this.host.std.get(EdgelessCRUDIdentifier); - const aiChatBlockId = crud.addBlock( + const forkBlockId = crud.addBlock( 'affine:embed-ai-chat', { xywh: bound.serialize(), messages: JSON.stringify(messages), - sessionId: this._chatSessionId, - rootWorkspaceId: parentRootWorkspaceId, - rootDocId: parentRootDocId, + sessionId: this._forkSessionId, + rootWorkspaceId: rootWorkspaceId, + rootDocId: rootDocId, }, surfaceBlock.id ); - if (!aiChatBlockId) { + if (!forkBlockId) { return; } - - this._chatBlockId = aiChatBlockId; + this._forkBlockId = forkBlockId; // Connect the parent chat block to the AI chat block crud.addElement(CanvasElementType.CONNECTOR, { mode: ConnectorMode.Curve, controllers: [], - source: { id: this.parentChatBlockId }, - target: { id: aiChatBlockId }, + source: { id: this.blockId }, + target: { id: forkBlockId }, }); const telemetryService = this.host.std.getOptional(TelemetryProvider); @@ -265,20 +258,20 @@ export class AIChatBlockPeekView extends LitElement { * Update the current chat messages with the new message */ updateChatBlockMessages = async () => { - if (!this._chatBlockId || !this._chatSessionId) { + if (!this._forkBlockId || !this._forkSessionId) { return; } const { doc } = this.host; - const chatBlock = doc.getBlock(this._chatBlockId); + const chatBlock = doc.getBlock(this._forkBlockId); if (!chatBlock) return; // Get fork session messages - const { parentRootWorkspaceId, parentRootDocId } = this; + const { rootWorkspaceId, rootDocId } = this; const messages = await this._constructBranchChatBlockMessages( - parentRootWorkspaceId, - parentRootDocId, - this._chatSessionId + rootWorkspaceId, + rootDocId, + this._forkSessionId ); if (!messages.length) { return; @@ -292,58 +285,35 @@ export class AIChatBlockPeekView extends LitElement { this.chatContext = { ...this.chatContext, ...context }; }; - updateChips = (chips: ChatChip[]) => { - this.chips = chips; + private readonly _updateEmbeddingProgress = ( + count: Record + ) => { + const total = count.finished + count.processing + count.failed; + this.embeddingProgress = [count.finished, total]; }; /** * Clean current chat messages and delete the newly created AI chat block */ - cleanCurrentChatHistories = async () => { - const notificationService = this.host.std.getOptional(NotificationProvider); - if (!notificationService) return; - - const { _chatBlockId, _chatSessionId } = this; - if (!_chatBlockId && !_chatSessionId) { - return; - } - - if ( - await notificationService.confirm({ - title: 'Clear History', - message: - 'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.', - confirmText: 'Confirm', - cancelText: 'Cancel', - }) - ) { - const { doc } = this.host; - if (_chatSessionId) { - await AIProvider.histories?.cleanup(doc.workspace.id, doc.id, [ - _chatSessionId, - ]); - } - - if (_chatBlockId) { - const surface = getSurfaceBlock(doc); - const crud = this.host.std.get(EdgelessCRUDIdentifier); - const chatBlock = doc.getBlock(_chatBlockId)?.model; - if (chatBlock) { - const connectors = surface?.getConnectors(chatBlock.id); - doc.transact(() => { - // Delete the AI chat block - crud.removeElement(_chatBlockId); - // Delete the connectors - connectors?.forEach(connector => { - crud.removeElement(connector.id); - }); + private readonly _onHistoryCleared = async () => { + const { _forkBlockId, host } = this; + if (_forkBlockId) { + const surface = getSurfaceBlock(host.doc); + const crud = host.std.get(EdgelessCRUDIdentifier); + const chatBlock = host.doc.getBlock(_forkBlockId)?.model; + if (chatBlock) { + const connectors = surface?.getConnectors(chatBlock.id); + host.doc.transact(() => { + // Delete the AI chat block + crud.removeElement(_forkBlockId); + // Delete the connectors + connectors?.forEach(connector => { + crud.removeElement(connector.id); }); - } + }); } - - notificationService.toast('History cleared'); - this._resetContext(); } + this._resetContext(); }; /** @@ -351,8 +321,8 @@ export class AIChatBlockPeekView extends LitElement { */ retry = async () => { const { doc } = this.host; - const { _chatBlockId, _chatSessionId } = this; - if (!_chatBlockId || !_chatSessionId) { + const { _forkBlockId, _forkSessionId } = this; + if (!_forkBlockId || !_forkSessionId) { return; } @@ -369,7 +339,7 @@ export class AIChatBlockPeekView extends LitElement { this.updateContext({ messages, status: 'loading', error: null }); const stream = AIProvider.actions.chat?.({ - sessionId: _chatSessionId, + sessionId: _forkSessionId, retry: true, docId: doc.id, workspaceId: doc.workspace.id, @@ -484,12 +454,8 @@ export class AIChatBlockPeekView extends LitElement { this._historyMessages = this._deserializeHistoryChatMessages( this.historyMessagesString ); - const { parentRootWorkspaceId, parentRootDocId, parentSessionId } = this; - queryHistoryMessages( - parentRootWorkspaceId, - parentRootDocId, - parentSessionId - ) + const { rootWorkspaceId, rootDocId, _sessionId } = this; + queryHistoryMessages(rootWorkspaceId, rootDocId, _sessionId) .then(messages => { this._historyMessages = this._historyMessages.map((message, idx) => { return { @@ -522,7 +488,6 @@ export class AIChatBlockPeekView extends LitElement { const latestHistoryMessage = _historyMessages[_historyMessages.length - 1]; const latestMessageCreatedAt = latestHistoryMessage.createdAt; const { - cleanCurrentChatHistories, chatContext, updateContext, networkSearchConfig, @@ -543,26 +508,27 @@ export class AIChatBlockPeekView extends LitElement { ${this.CurrentMessages(currentChatMessages)} - - + .portalContainer=${this.parentElement} + > `; } @@ -570,7 +536,7 @@ export class AIChatBlockPeekView extends LitElement { accessor _chatMessagesContainer!: HTMLDivElement; @property({ attribute: false }) - accessor parentModel!: AIChatBlockModel; + accessor blockModel!: AIChatBlockModel; @property({ attribute: false }) accessor host!: EditorHost; @@ -600,7 +566,7 @@ export class AIChatBlockPeekView extends LitElement { }; @state() - accessor chips: ChatChip[] = []; + accessor embeddingProgress: [number, number] = [0, 0]; } declare global { @@ -610,7 +576,7 @@ declare global { } export const AIChatBlockPeekViewTemplate = ( - parentModel: AIChatBlockModel, + blockModel: AIChatBlockModel, host: EditorHost, previewSpecBuilder: SpecBuilder, docDisplayConfig: DocDisplayConfig, @@ -618,7 +584,7 @@ export const AIChatBlockPeekViewTemplate = ( networkSearchConfig: AINetworkSearchConfig ) => { return html`