diff --git a/packages/frontend/core/src/blocksuite/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/ai/actions/types.ts index 9fd8fb98db..a2e5a110d0 100644 --- a/packages/frontend/core/src/blocksuite/ai/actions/types.ts +++ b/packages/frontend/core/src/blocksuite/ai/actions/types.ts @@ -7,8 +7,10 @@ import type { CopilotContextFile, CopilotSessionType, getCopilotHistoriesQuery, + QueryChatSessionsInput, RequestOptions, StreamObject, + UpdateChatSessionInput, } from '@affine/graphql'; import type { EditorHost } from '@blocksuite/affine/std'; import type { GfxModel } from '@blocksuite/affine/std/gfx'; @@ -374,26 +376,29 @@ declare global { >[]; }; - interface CreateSessionOptions { + interface AICreateSessionOptions { promptName: PromptKey; workspaceId: string; docId?: string; sessionId?: string; retry?: boolean; + pinned?: boolean; + // default value of reuseLatestChat is true at backend + reuseLatestChat?: boolean; } interface AISessionService { - createSession: (options: CreateSessionOptions) => Promise; + createSession: (options: AICreateSessionOptions) => Promise; getSessions: ( workspaceId: string, docId?: string, - options?: { action?: boolean } + options?: QueryChatSessionsInput ) => Promise; getSession: ( workspaceId: string, sessionId: string ) => Promise; - updateSession: (sessionId: string, promptName: string) => Promise; + updateSession: (options: UpdateChatSessionInput) => Promise; } interface AIHistoryService { 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 95b3051753..bca2759183 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -1,6 +1,10 @@ import type { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; -import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql'; +import type { + ContextEmbedStatus, + CopilotSessionType, + UpdateChatSessionInput, +} from '@affine/graphql'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import type { EditorHost } from '@blocksuite/affine/std'; @@ -99,29 +103,44 @@ export class ChatPanel extends SignalWatcher( @state() accessor embeddingProgress: [number, number] = [0, 0]; - private isSidebarOpen: Signal = signal(false); - - private sidebarWidth: Signal = signal(undefined); @state() accessor showPreviewPanel = false; + private isSidebarOpen: Signal = signal(false); + + private sidebarWidth: Signal = signal(undefined); + + private hasPinned = false; + + private get isInitialized() { + return this.session !== undefined; + } + private readonly initSession = async () => { - if (this.session) { - return this.session; + if (!AIProvider.session) { + return; } - const sessions = ( - (await AIProvider.session?.getSessions( + const pinSessions = await AIProvider.session.getSessions( + this.doc.workspace.id, + undefined, + { pinned: true } + ); + if (Array.isArray(pinSessions) && pinSessions[0]) { + this.session = pinSessions[0]; + } else { + const docSessions = await AIProvider.session.getSessions( this.doc.workspace.id, this.doc.id, - { action: false } - )) || [] - ).filter(session => !session.parentSessionId); - const session = sessions.at(-1); - this.session = session ?? null; - return session; + { action: false, fork: false } + ); + // the first item is the latest session + this.session = docSessions?.[0] ?? null; + } }; - private readonly createSession = async () => { + private readonly createSession = async ( + options: Partial = {} + ) => { if (this.session) { return this.session; } @@ -129,6 +148,8 @@ export class ChatPanel extends SignalWatcher( docId: this.doc.id, workspaceId: this.doc.workspace.id, promptName: 'Chat With AFFiNE AI', + reuseLatestChat: false, + ...options, }); if (sessionId) { const session = await AIProvider.session?.getSession( @@ -140,12 +161,42 @@ export class ChatPanel extends SignalWatcher( return this.session; }; + private readonly updateSession = async (options: UpdateChatSessionInput) => { + await AIProvider.session?.updateSession(options); + const session = await AIProvider.session?.getSession( + this.doc.workspace.id, + options.sessionId + ); + this.session = session ?? null; + }; + + private readonly newSession = () => { + this.resetPanel(); + requestAnimationFrame(() => { + this.session = null; + }); + }; + + private readonly togglePin = async () => { + const pinned = !this.session?.pinned; + this.hasPinned = true; + if (!this.session) { + await this.createSession({ pinned }); + } else { + await this.updateSession({ + sessionId: this.session.id, + pinned, + }); + } + }; + private readonly initPanel = async () => { try { if (!this.isSidebarOpen.value) { return; } await this.initSession(); + this.hasPinned = !!this.session?.pinned; } catch (error) { console.error(error); } @@ -154,6 +205,8 @@ export class ChatPanel extends SignalWatcher( private readonly resetPanel = () => { this.session = undefined; this.embeddingProgress = [0, 0]; + this.showPreviewPanel = false; + this.hasPinned = false; }; private readonly updateEmbeddingProgress = ( @@ -184,6 +237,9 @@ export class ChatPanel extends SignalWatcher( protected override updated(changedProperties: PropertyValues) { if (changedProperties.has('doc')) { + if (this.session?.pinned) { + return; + } this.resetPanel(); this.initPanel().catch(console.error); } @@ -209,8 +265,8 @@ export class ChatPanel extends SignalWatcher( this._disposables.add(width.cleanup); this._disposables.add( - this.isSidebarOpen.subscribe(() => { - if (this.session === undefined) { + this.isSidebarOpen.subscribe(isOpen => { + if (isOpen && !this.isInitialized) { this.initPanel().catch(console.error); } }) @@ -218,8 +274,7 @@ export class ChatPanel extends SignalWatcher( } override render() { - const isInitialized = this.session !== undefined; - if (!isInitialized) { + if (!this.isInitialized) { return nothing; } @@ -244,11 +299,16 @@ export class ChatPanel extends SignalWatcher( ` : nothing} + `; const left = html`
${keyed( - this.doc.id, + this.hasPinned ? this.session?.id : this.doc.id, html` this.retry()} > - ${isLast && !!markdown && host + ${isLast && showActions ? html` { const currentRequest = ++this.updateHistoryCounter; if (!AIProvider.histories) { @@ -139,9 +141,14 @@ export class AIChatContent extends SignalWatcher( } const sessionId = this.session?.id; + const pinned = this.session?.pinned; const [histories, actions] = await Promise.all([ sessionId - ? AIProvider.histories.chats(this.workspaceId, sessionId, this.docId) + ? AIProvider.histories.chats( + this.workspaceId, + sessionId, + pinned ? undefined : this.docId + ) : Promise.resolve([]), this.docId ? AIProvider.histories.actions(this.workspaceId, this.docId) @@ -153,7 +160,9 @@ export class AIChatContent extends SignalWatcher( return; } - const messages: HistoryMessage[] = this.chatContextValue.messages.slice(); + const messages: HistoryMessage[] = this.chatContextValue.messages + .slice() + .filter(isChatMessage); const chatActions = (actions || []) as ChatAction[]; messages.push(...chatActions); @@ -168,6 +177,7 @@ export class AIChatContent extends SignalWatcher( ), }); + this.wheelTriggered = false; this.scrollToEnd(); }; @@ -192,6 +202,9 @@ export class AIChatContent extends SignalWatcher( ), }); } + + this.wheelTriggered = false; + this.scrollToEnd(); }; private readonly updateContext = (context: Partial) => { @@ -217,9 +230,13 @@ export class AIChatContent extends SignalWatcher( if (chatMessages) { chatMessages.updateComplete .then(() => { - chatMessages.getScrollContainer()?.addEventListener('wheel', () => { + const scrollContainer = chatMessages.getScrollContainer(); + scrollContainer?.addEventListener('wheel', () => { this.wheelTriggered = true; }); + scrollContainer?.addEventListener('scrollend', () => { + this.lastScrollTop = scrollContainer.scrollTop; + }); }) .catch(console.error); } @@ -246,6 +263,15 @@ export class AIChatContent extends SignalWatcher( ) { this._throttledScrollToEnd(); } + + // restore pinned chat scroll position + if ( + changedProperties.has('host') && + this.session?.pinned && + this.lastScrollTop !== undefined + ) { + this.chatMessagesRef.value?.scrollToPos(this.lastScrollTop); + } } override connectedCallback() { diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts index f3c230d793..e7f2e5021a 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts @@ -301,6 +301,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) { } else if (isChatMessage(item) && item.role === 'assistant') { return html` { + if (!this.messagesContainer) return; + this.messagesContainer.scrollTo({ top }); + }); + } + retry = async () => { try { const sessionId = (await this.createSession())?.id; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts new file mode 100644 index 0000000000..edd2cb2e04 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts @@ -0,0 +1,72 @@ +import type { CopilotSessionType } from '@affine/graphql'; +import { WithDisposable } from '@blocksuite/affine/global/lit'; +import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; +import { ShadowlessElement } from '@blocksuite/affine/std'; +import { + ArrowDownSmallIcon, + PinedIcon, + PinIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class AIChatToolbar extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor session!: CopilotSessionType | null | undefined; + + @property({ attribute: false }) + accessor onNewSession!: () => void; + + @property({ attribute: false }) + accessor onTogglePin!: () => void; + + static override styles = css` + .ai-chat-toolbar { + display: flex; + gap: 8px; + align-items: center; + + .chat-toolbar-icon { + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + border-radius: 4px; + &:hover { + background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + svg { + width: 16px; + height: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + } + } + `; + + override render() { + const pined = this.session?.pinned; + return html` +
+
+ ${PlusIcon()} + New Chat +
+
+ ${pined ? PinedIcon() : PinIcon()} + ${pined ? 'Unpin this Chat' : 'Pin this Chat'} +
+
+ ${ArrowDownSmallIcon()} + Chat History +
+
+ `; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts new file mode 100644 index 0000000000..794632fb26 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts @@ -0,0 +1 @@ +export * from './ai-chat-toolbar'; diff --git a/packages/frontend/core/src/blocksuite/ai/components/copy-more.ts b/packages/frontend/core/src/blocksuite/ai/components/copy-more.ts index a63fc575cd..f1ed18eb1b 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/copy-more.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/copy-more.ts @@ -165,6 +165,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) { override render() { const { host, content, isLast, messageId, actions } = this; + const showMoreIcon = !isLast && host && actions.length > 0; return html`