feat(core): support ai chat add, pin and unpin (#13002)

Close [AI-241](https://linear.app/affine-design/issue/AI-241)
Close [AI-237](https://linear.app/affine-design/issue/AI-237)
Close [AI-238](https://linear.app/affine-design/issue/AI-238)

<img width="564" alt="截屏2025-07-03 15 54 10"
src="https://github.com/user-attachments/assets/8654db2b-cb71-4906-9e3b-0a723d7459e1"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a chat toolbar with options to create new sessions and
pin/unpin chat sessions.
* Enhanced session management, allowing users to pin sessions and
control session reuse.
* Added the ability to update session properties directly from the chat
panel.

* **Improvements**
* Chat panel now prioritizes pinned sessions and provides clearer
session initialization.
* Editor actions in chat messages are shown only when relevant document
information is present.
  * Toolbar and chat content UI improved for clarity and usability.
  * Scroll position is preserved and restored for pinned chat sessions.
* Session API updated to support more structured input types and new
session creation options.

* **Bug Fixes**
* Actions and toolbar buttons are now conditionally displayed based on
session and message state.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-07-03 19:26:36 +08:00
committed by GitHub
parent 92cd2a3d0e
commit 134e62a0fa
10 changed files with 221 additions and 47 deletions

View File

@@ -7,8 +7,10 @@ import type {
CopilotContextFile, CopilotContextFile,
CopilotSessionType, CopilotSessionType,
getCopilotHistoriesQuery, getCopilotHistoriesQuery,
QueryChatSessionsInput,
RequestOptions, RequestOptions,
StreamObject, StreamObject,
UpdateChatSessionInput,
} from '@affine/graphql'; } from '@affine/graphql';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import type { GfxModel } from '@blocksuite/affine/std/gfx'; import type { GfxModel } from '@blocksuite/affine/std/gfx';
@@ -374,26 +376,29 @@ declare global {
>[]; >[];
}; };
interface CreateSessionOptions { interface AICreateSessionOptions {
promptName: PromptKey; promptName: PromptKey;
workspaceId: string; workspaceId: string;
docId?: string; docId?: string;
sessionId?: string; sessionId?: string;
retry?: boolean; retry?: boolean;
pinned?: boolean;
// default value of reuseLatestChat is true at backend
reuseLatestChat?: boolean;
} }
interface AISessionService { interface AISessionService {
createSession: (options: CreateSessionOptions) => Promise<string>; createSession: (options: AICreateSessionOptions) => Promise<string>;
getSessions: ( getSessions: (
workspaceId: string, workspaceId: string,
docId?: string, docId?: string,
options?: { action?: boolean } options?: QueryChatSessionsInput
) => Promise<CopilotSessionType[] | undefined>; ) => Promise<CopilotSessionType[] | undefined>;
getSession: ( getSession: (
workspaceId: string, workspaceId: string,
sessionId: string sessionId: string
) => Promise<CopilotSessionType | undefined>; ) => Promise<CopilotSessionType | undefined>;
updateSession: (sessionId: string, promptName: string) => Promise<string>; updateSession: (options: UpdateChatSessionInput) => Promise<string>;
} }
interface AIHistoryService { interface AIHistoryService {

View File

@@ -1,6 +1,10 @@
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; 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 { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
@@ -99,29 +103,44 @@ export class ChatPanel extends SignalWatcher(
@state() @state()
accessor embeddingProgress: [number, number] = [0, 0]; accessor embeddingProgress: [number, number] = [0, 0];
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = signal(undefined);
@state() @state()
accessor showPreviewPanel = false; accessor showPreviewPanel = false;
private readonly initSession = async () => { private isSidebarOpen: Signal<boolean | undefined> = signal(false);
if (this.session) {
return this.session; private sidebarWidth: Signal<number | undefined> = signal(undefined);
private hasPinned = false;
private get isInitialized() {
return this.session !== undefined;
} }
const sessions = (
(await AIProvider.session?.getSessions( private readonly initSession = async () => {
if (!AIProvider.session) {
return;
}
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.workspace.id,
this.doc.id, this.doc.id,
{ action: false } { action: false, fork: false }
)) || [] );
).filter(session => !session.parentSessionId); // the first item is the latest session
const session = sessions.at(-1); this.session = docSessions?.[0] ?? null;
this.session = session ?? null; }
return session;
}; };
private readonly createSession = async () => { private readonly createSession = async (
options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}
) => {
if (this.session) { if (this.session) {
return this.session; return this.session;
} }
@@ -129,6 +148,8 @@ export class ChatPanel extends SignalWatcher(
docId: this.doc.id, docId: this.doc.id,
workspaceId: this.doc.workspace.id, workspaceId: this.doc.workspace.id,
promptName: 'Chat With AFFiNE AI', promptName: 'Chat With AFFiNE AI',
reuseLatestChat: false,
...options,
}); });
if (sessionId) { if (sessionId) {
const session = await AIProvider.session?.getSession( const session = await AIProvider.session?.getSession(
@@ -140,12 +161,42 @@ export class ChatPanel extends SignalWatcher(
return this.session; 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 () => { private readonly initPanel = async () => {
try { try {
if (!this.isSidebarOpen.value) { if (!this.isSidebarOpen.value) {
return; return;
} }
await this.initSession(); await this.initSession();
this.hasPinned = !!this.session?.pinned;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -154,6 +205,8 @@ export class ChatPanel extends SignalWatcher(
private readonly resetPanel = () => { private readonly resetPanel = () => {
this.session = undefined; this.session = undefined;
this.embeddingProgress = [0, 0]; this.embeddingProgress = [0, 0];
this.showPreviewPanel = false;
this.hasPinned = false;
}; };
private readonly updateEmbeddingProgress = ( private readonly updateEmbeddingProgress = (
@@ -184,6 +237,9 @@ export class ChatPanel extends SignalWatcher(
protected override updated(changedProperties: PropertyValues) { protected override updated(changedProperties: PropertyValues) {
if (changedProperties.has('doc')) { if (changedProperties.has('doc')) {
if (this.session?.pinned) {
return;
}
this.resetPanel(); this.resetPanel();
this.initPanel().catch(console.error); this.initPanel().catch(console.error);
} }
@@ -209,8 +265,8 @@ export class ChatPanel extends SignalWatcher(
this._disposables.add(width.cleanup); this._disposables.add(width.cleanup);
this._disposables.add( this._disposables.add(
this.isSidebarOpen.subscribe(() => { this.isSidebarOpen.subscribe(isOpen => {
if (this.session === undefined) { if (isOpen && !this.isInitialized) {
this.initPanel().catch(console.error); this.initPanel().catch(console.error);
} }
}) })
@@ -218,8 +274,7 @@ export class ChatPanel extends SignalWatcher(
} }
override render() { override render() {
const isInitialized = this.session !== undefined; if (!this.isInitialized) {
if (!isInitialized) {
return nothing; return nothing;
} }
@@ -244,11 +299,16 @@ export class ChatPanel extends SignalWatcher(
</div> </div>
` `
: nothing} : nothing}
<ai-chat-toolbar
.session=${this.session}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
></ai-chat-toolbar>
`; `;
const left = html`<div class="chat-panel-container" style=${style}> const left = html`<div class="chat-panel-container" style=${style}>
${keyed( ${keyed(
this.doc.id, this.hasPinned ? this.session?.id : this.doc.id,
html`<ai-chat-content html`<ai-chat-content
.chatTitle=${title} .chatTitle=${title}
.host=${this.host} .host=${this.host}

View File

@@ -35,6 +35,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor host: EditorHost | null | undefined; accessor host: EditorHost | null | undefined;
@property({ attribute: false })
accessor docId: string | undefined;
@property({ attribute: false }) @property({ attribute: false })
accessor item!: ChatMessage; accessor item!: ChatMessage;
@@ -135,7 +138,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
} }
private renderEditorActions() { private renderEditorActions() {
const { item, isLast, status, host, session } = this; const { item, isLast, status, host, session, docId } = this;
if (!isChatMessage(item) || item.role !== 'assistant') return nothing; if (!isChatMessage(item) || item.role !== 'assistant') return nothing;
@@ -158,18 +161,20 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
: EdgelessEditorActions : EdgelessEditorActions
: null; : null;
const showActions = host && docId && !!markdown;
return html` return html`
<chat-copy-more <chat-copy-more
.host=${host} .host=${host}
.session=${session} .session=${session}
.actions=${actions} .actions=${showActions ? actions : []}
.content=${markdown} .content=${markdown}
.isLast=${isLast} .isLast=${isLast}
.messageId=${messageId} .messageId=${messageId}
.withMargin=${true} .withMargin=${true}
.retry=${() => this.retry()} .retry=${() => this.retry()}
></chat-copy-more> ></chat-copy-more>
${isLast && !!markdown && host ${isLast && showActions
? html`<chat-action-list ? html`<chat-action-list
.actions=${actions} .actions=${actions}
.host=${host} .host=${host}

View File

@@ -132,6 +132,8 @@ export class AIChatContent extends SignalWatcher(
private wheelTriggered = false; private wheelTriggered = false;
private lastScrollTop: number | undefined;
private readonly updateHistory = async () => { private readonly updateHistory = async () => {
const currentRequest = ++this.updateHistoryCounter; const currentRequest = ++this.updateHistoryCounter;
if (!AIProvider.histories) { if (!AIProvider.histories) {
@@ -139,9 +141,14 @@ export class AIChatContent extends SignalWatcher(
} }
const sessionId = this.session?.id; const sessionId = this.session?.id;
const pinned = this.session?.pinned;
const [histories, actions] = await Promise.all([ const [histories, actions] = await Promise.all([
sessionId sessionId
? AIProvider.histories.chats(this.workspaceId, sessionId, this.docId) ? AIProvider.histories.chats(
this.workspaceId,
sessionId,
pinned ? undefined : this.docId
)
: Promise.resolve([]), : Promise.resolve([]),
this.docId this.docId
? AIProvider.histories.actions(this.workspaceId, this.docId) ? AIProvider.histories.actions(this.workspaceId, this.docId)
@@ -153,7 +160,9 @@ export class AIChatContent extends SignalWatcher(
return; return;
} }
const messages: HistoryMessage[] = this.chatContextValue.messages.slice(); const messages: HistoryMessage[] = this.chatContextValue.messages
.slice()
.filter(isChatMessage);
const chatActions = (actions || []) as ChatAction[]; const chatActions = (actions || []) as ChatAction[];
messages.push(...chatActions); messages.push(...chatActions);
@@ -168,6 +177,7 @@ export class AIChatContent extends SignalWatcher(
), ),
}); });
this.wheelTriggered = false;
this.scrollToEnd(); this.scrollToEnd();
}; };
@@ -192,6 +202,9 @@ export class AIChatContent extends SignalWatcher(
), ),
}); });
} }
this.wheelTriggered = false;
this.scrollToEnd();
}; };
private readonly updateContext = (context: Partial<ChatContextValue>) => { private readonly updateContext = (context: Partial<ChatContextValue>) => {
@@ -217,9 +230,13 @@ export class AIChatContent extends SignalWatcher(
if (chatMessages) { if (chatMessages) {
chatMessages.updateComplete chatMessages.updateComplete
.then(() => { .then(() => {
chatMessages.getScrollContainer()?.addEventListener('wheel', () => { const scrollContainer = chatMessages.getScrollContainer();
scrollContainer?.addEventListener('wheel', () => {
this.wheelTriggered = true; this.wheelTriggered = true;
}); });
scrollContainer?.addEventListener('scrollend', () => {
this.lastScrollTop = scrollContainer.scrollTop;
});
}) })
.catch(console.error); .catch(console.error);
} }
@@ -246,6 +263,15 @@ export class AIChatContent extends SignalWatcher(
) { ) {
this._throttledScrollToEnd(); 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() { override connectedCallback() {

View File

@@ -301,6 +301,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
} else if (isChatMessage(item) && item.role === 'assistant') { } else if (isChatMessage(item) && item.role === 'assistant') {
return html`<chat-message-assistant return html`<chat-message-assistant
.host=${this.host} .host=${this.host}
.docId=${this.docId}
.session=${this.session} .session=${this.session}
.item=${item} .item=${item}
.isLast=${isLast} .isLast=${isLast}
@@ -393,6 +394,13 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
}); });
} }
scrollToPos(top: number) {
requestAnimationFrame(() => {
if (!this.messagesContainer) return;
this.messagesContainer.scrollTo({ top });
});
}
retry = async () => { retry = async () => {
try { try {
const sessionId = (await this.createSession())?.id; const sessionId = (await this.createSession())?.id;

View File

@@ -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`
<div class="ai-chat-toolbar">
<div class="chat-toolbar-icon" @click=${this.onNewSession}>
${PlusIcon()}
<affine-tooltip>New Chat</affine-tooltip>
</div>
<div class="chat-toolbar-icon" @click=${this.onTogglePin}>
${pined ? PinedIcon() : PinIcon()}
<affine-tooltip
>${pined ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip
>
</div>
<div class="chat-toolbar-icon">
${ArrowDownSmallIcon()}
<affine-tooltip>Chat History</affine-tooltip>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './ai-chat-toolbar';

View File

@@ -165,6 +165,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
override render() { override render() {
const { host, content, isLast, messageId, actions } = this; const { host, content, isLast, messageId, actions } = this;
const showMoreIcon = !isLast && host && actions.length > 0;
return html`<style> return html`<style>
.copy-more { .copy-more {
margin-top: ${this.withMargin ? '8px' : '0px'}; margin-top: ${this.withMargin ? '8px' : '0px'};
@@ -200,7 +201,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
<affine-tooltip .autoShift=${true}>Retry</affine-tooltip> <affine-tooltip .autoShift=${true}>Retry</affine-tooltip>
</div>` </div>`
: nothing} : nothing}
${!isLast && host ${showMoreIcon
? html`<div ? html`<div
class="button more" class="button more"
data-testid="action-more-button" data-testid="action-more-button"

View File

@@ -38,6 +38,7 @@ import { AIChatInput } from './components/ai-chat-input';
import { AIChatEmbeddingStatusTooltip } from './components/ai-chat-input/embedding-status-tooltip'; import { AIChatEmbeddingStatusTooltip } from './components/ai-chat-input/embedding-status-tooltip';
import { ChatInputPreference } from './components/ai-chat-input/preference-popup'; import { ChatInputPreference } from './components/ai-chat-input/preference-popup';
import { AIChatMessages } from './components/ai-chat-messages/ai-chat-messages'; import { AIChatMessages } from './components/ai-chat-messages/ai-chat-messages';
import { AIChatToolbar } from './components/ai-chat-toolbar';
import { AIHistoryClear } from './components/ai-history-clear'; import { AIHistoryClear } from './components/ai-history-clear';
import { effects as componentAiItemEffects } from './components/ai-item'; import { effects as componentAiItemEffects } from './components/ai-item';
import { AssistantAvatar } from './components/ai-message-content/assistant-avatar'; import { AssistantAvatar } from './components/ai-message-content/assistant-avatar';
@@ -107,6 +108,7 @@ export function registerAIEffects() {
customElements.define('action-text', ActionText); customElements.define('action-text', ActionText);
customElements.define('ai-loading', AILoading); customElements.define('ai-loading', AILoading);
customElements.define('ai-chat-content', AIChatContent); customElements.define('ai-chat-content', AIChatContent);
customElements.define('ai-chat-toolbar', AIChatToolbar);
customElements.define('ai-chat-messages', AIChatMessages); customElements.define('ai-chat-messages', AIChatMessages);
customElements.define('chat-panel', ChatPanel); customElements.define('chat-panel', ChatPanel);
customElements.define('ai-chat-input', AIChatInput); customElements.define('ai-chat-input', AIChatInput);

View File

@@ -5,7 +5,9 @@ import {
ContextCategories, ContextCategories,
type ContextWorkspaceEmbeddingStatus, type ContextWorkspaceEmbeddingStatus,
type getCopilotHistoriesQuery, type getCopilotHistoriesQuery,
type QueryChatSessionsInput,
type RequestOptions, type RequestOptions,
type UpdateChatSessionInput,
} from '@affine/graphql'; } from '@affine/graphql';
import { z } from 'zod'; import { z } from 'zod';
@@ -42,14 +44,6 @@ const processTypeToPromptName = new Map<string, PromptKey>(
}) })
); );
interface CreateSessionOptions {
promptName: PromptKey;
workspaceId: string;
docId?: string;
sessionId?: string;
retry?: boolean;
}
export function setupAIProvider( export function setupAIProvider(
client: CopilotClient, client: CopilotClient,
globalDialogService: GlobalDialogService, globalDialogService: GlobalDialogService,
@@ -61,7 +55,9 @@ export function setupAIProvider(
docId, docId,
sessionId, sessionId,
retry, retry,
}: CreateSessionOptions) { pinned,
reuseLatestChat,
}: BlockSuitePresets.AICreateSessionOptions) {
if (sessionId) return sessionId; if (sessionId) return sessionId;
if (retry) return AIProvider.LAST_ACTION_SESSIONID; if (retry) return AIProvider.LAST_ACTION_SESSIONID;
@@ -69,6 +65,8 @@ export function setupAIProvider(
workspaceId, workspaceId,
docId, docId,
promptName, promptName,
pinned,
reuseLatestChat,
}); });
} }
@@ -586,16 +584,12 @@ Could you make a new website based on these notes and send back just the html fi
getSessions: async ( getSessions: async (
workspaceId: string, workspaceId: string,
docId?: string, docId?: string,
options?: { action?: boolean } options?: QueryChatSessionsInput
) => { ) => {
return client.getSessions(workspaceId, docId, options); return client.getSessions(workspaceId, docId, options);
}, },
updateSession: async (sessionId: string, promptName: string) => { updateSession: async (options: UpdateChatSessionInput) => {
return client.updateSession({ return client.updateSession(options);
sessionId,
promptName,
// TODO(@yoyoyohamapi): update docId & pinned for chat independence
});
}, },
}); });