mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ai-chat-toolbar';
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user