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,
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<string>;
createSession: (options: AICreateSessionOptions) => Promise<string>;
getSessions: (
workspaceId: string,
docId?: string,
options?: { action?: boolean }
options?: QueryChatSessionsInput
) => Promise<CopilotSessionType[] | undefined>;
getSession: (
workspaceId: string,
sessionId: string
) => Promise<CopilotSessionType | undefined>;
updateSession: (sessionId: string, promptName: string) => Promise<string>;
updateSession: (options: UpdateChatSessionInput) => Promise<string>;
}
interface AIHistoryService {

View File

@@ -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<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = signal(undefined);
@state()
accessor showPreviewPanel = false;
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = 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<BlockSuitePresets.AICreateSessionOptions> = {}
) => {
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(
</div>
`
: 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}>
${keyed(
this.doc.id,
this.hasPinned ? this.session?.id : this.doc.id,
html`<ai-chat-content
.chatTitle=${title}
.host=${this.host}

View File

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

View File

@@ -132,6 +132,8 @@ export class AIChatContent extends SignalWatcher(
private wheelTriggered = false;
private lastScrollTop: number | undefined;
private readonly updateHistory = async () => {
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<ChatContextValue>) => {
@@ -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() {

View File

@@ -301,6 +301,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
} else if (isChatMessage(item) && item.role === 'assistant') {
return html`<chat-message-assistant
.host=${this.host}
.docId=${this.docId}
.session=${this.session}
.item=${item}
.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 () => {
try {
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() {
const { host, content, isLast, messageId, actions } = this;
const showMoreIcon = !isLast && host && actions.length > 0;
return html`<style>
.copy-more {
margin-top: ${this.withMargin ? '8px' : '0px'};
@@ -200,7 +201,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
<affine-tooltip .autoShift=${true}>Retry</affine-tooltip>
</div>`
: nothing}
${!isLast && host
${showMoreIcon
? html`<div
class="button more"
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 { ChatInputPreference } from './components/ai-chat-input/preference-popup';
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 { effects as componentAiItemEffects } from './components/ai-item';
import { AssistantAvatar } from './components/ai-message-content/assistant-avatar';
@@ -107,6 +108,7 @@ export function registerAIEffects() {
customElements.define('action-text', ActionText);
customElements.define('ai-loading', AILoading);
customElements.define('ai-chat-content', AIChatContent);
customElements.define('ai-chat-toolbar', AIChatToolbar);
customElements.define('ai-chat-messages', AIChatMessages);
customElements.define('chat-panel', ChatPanel);
customElements.define('ai-chat-input', AIChatInput);

View File

@@ -5,7 +5,9 @@ import {
ContextCategories,
type ContextWorkspaceEmbeddingStatus,
type getCopilotHistoriesQuery,
type QueryChatSessionsInput,
type RequestOptions,
type UpdateChatSessionInput,
} from '@affine/graphql';
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(
client: CopilotClient,
globalDialogService: GlobalDialogService,
@@ -61,7 +55,9 @@ export function setupAIProvider(
docId,
sessionId,
retry,
}: CreateSessionOptions) {
pinned,
reuseLatestChat,
}: BlockSuitePresets.AICreateSessionOptions) {
if (sessionId) return sessionId;
if (retry) return AIProvider.LAST_ACTION_SESSIONID;
@@ -69,6 +65,8 @@ export function setupAIProvider(
workspaceId,
docId,
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 (
workspaceId: string,
docId?: string,
options?: { action?: boolean }
options?: QueryChatSessionsInput
) => {
return client.getSessions(workspaceId, docId, options);
},
updateSession: async (sessionId: string, promptName: string) => {
return client.updateSession({
sessionId,
promptName,
// TODO(@yoyoyohamapi): update docId & pinned for chat independence
});
updateSession: async (options: UpdateChatSessionInput) => {
return client.updateSession(options);
},
});