feat(core): support ai recent session history (#13025)

Close [AI-239](https://linear.app/affine-design/issue/AI-239)
Close [AI-240](https://linear.app/affine-design/issue/AI-240)
Close [AI-242](https://linear.app/affine-design/issue/AI-242)

<img width="365" alt="截屏2025-07-04 13 49 25"
src="https://github.com/user-attachments/assets/d7c830f0-cc16-4a26-baf1-480c7d42838f"
/>


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

* **New Features**
* Introduced a floating chat history menu, allowing users to view and
switch between recent AI chat sessions grouped by recency.
* Added a new component for displaying recent chat sessions with
document icons and titles.
* Enhanced chat toolbar with asynchronous confirmation dialogs before
switching or creating sessions.
* Added notification support for chat-related actions and history
clearing.
* Added ability to fetch and display recent AI chat sessions per
workspace.

* **Improvements**
  * Streamlined session management and event handling in the chat panel.
* Improved embedding progress update and context change handling across
chat components.
  * Refined UI for chat history, session switching, and notifications.
* Updated chat components to use direct notification service injection
for better user prompts and toasts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-07-04 14:51:35 +08:00
committed by GitHub
parent eb73c90b2e
commit 24f1181069
14 changed files with 462 additions and 62 deletions

View File

@@ -5,6 +5,7 @@ import type {
CopilotContextCategory, CopilotContextCategory,
CopilotContextDoc, CopilotContextDoc,
CopilotContextFile, CopilotContextFile,
CopilotHistories,
CopilotSessionType, CopilotSessionType,
getCopilotHistoriesQuery, getCopilotHistoriesQuery,
QueryChatSessionsInput, QueryChatSessionsInput,
@@ -387,6 +388,8 @@ declare global {
reuseLatestChat?: boolean; reuseLatestChat?: boolean;
} }
type AIRecentSession = Omit<CopilotHistories, 'messages'>;
interface AISessionService { interface AISessionService {
createSession: (options: AICreateSessionOptions) => Promise<string>; createSession: (options: AICreateSessionOptions) => Promise<string>;
getSessions: ( getSessions: (
@@ -394,6 +397,10 @@ declare global {
docId?: string, docId?: string,
options?: QueryChatSessionsInput options?: QueryChatSessionsInput
) => Promise<CopilotSessionType[] | undefined>; ) => Promise<CopilotSessionType[] | undefined>;
getRecentSessions: (
workspaceId: string,
limit?: number
) => Promise<AIRecentSession[] | undefined>;
getSession: ( getSession: (
workspaceId: string, workspaceId: string,
sessionId: string sessionId: string

View File

@@ -6,6 +6,7 @@ import type {
UpdateChatSessionInput, UpdateChatSessionInput,
} from '@affine/graphql'; } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { NotificationProvider } from '@blocksuite/affine/shared/services';
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';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -27,6 +28,7 @@ import type {
DocDisplayConfig, DocDisplayConfig,
SearchMenuConfig, SearchMenuConfig,
} from '../components/ai-chat-chips'; } from '../components/ai-chat-chips';
import type { ChatContextValue } from '../components/ai-chat-content';
import type { import type {
AINetworkSearchConfig, AINetworkSearchConfig,
AIPlaygroundConfig, AIPlaygroundConfig,
@@ -125,6 +127,38 @@ export class ChatPanel extends SignalWatcher(
return this.session !== undefined; return this.session !== undefined;
} }
private get chatTitle() {
const [done, total] = this.embeddingProgress;
const isEmbedding = total > 0 && done < total;
const notification = this.host.std.getOptional(NotificationProvider);
return html`
<div class="chat-panel-title-text">
${isEmbedding
? html`<span data-testid="chat-panel-embedding-progress"
>Embedding ${done}/${total}</span
>`
: 'AFFiNE AI'}
</div>
${this.playgroundConfig.visible.value
? html`
<div class="chat-panel-playground" @click=${this.openPlayground}>
${CenterPeekIcon()}
</div>
`
: nothing}
<ai-chat-toolbar
.session=${this.session}
.workspaceId=${this.doc.workspace.id}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
.docDisplayConfig=${this.docDisplayConfig}
.notification=${notification}
></ai-chat-toolbar>
`;
}
private readonly initSession = async () => { private readonly initSession = async () => {
if (!AIProvider.session) { if (!AIProvider.session) {
return; return;
@@ -132,7 +166,7 @@ export class ChatPanel extends SignalWatcher(
const pinSessions = await AIProvider.session.getSessions( const pinSessions = await AIProvider.session.getSessions(
this.doc.workspace.id, this.doc.workspace.id,
undefined, undefined,
{ pinned: true } { pinned: true, limit: 1 }
); );
if (Array.isArray(pinSessions) && pinSessions[0]) { if (Array.isArray(pinSessions) && pinSessions[0]) {
this.session = pinSessions[0]; this.session = pinSessions[0];
@@ -140,7 +174,7 @@ export class ChatPanel extends SignalWatcher(
const docSessions = await AIProvider.session.getSessions( const docSessions = await AIProvider.session.getSessions(
this.doc.workspace.id, this.doc.workspace.id,
this.doc.id, this.doc.id,
{ action: false, fork: false } { action: false, fork: false, limit: 1 }
); );
// the first item is the latest session // the first item is the latest session
this.session = docSessions?.[0] ?? null; this.session = docSessions?.[0] ?? null;
@@ -186,6 +220,15 @@ export class ChatPanel extends SignalWatcher(
}); });
}; };
private readonly openSession = async (sessionId: string) => {
this.resetPanel();
const session = await AIProvider.session?.getSession(
this.doc.workspace.id,
sessionId
);
this.session = session ?? null;
};
private readonly togglePin = async () => { private readonly togglePin = async () => {
const pinned = !this.session?.pinned; const pinned = !this.session?.pinned;
this.hasPinned = true; this.hasPinned = true;
@@ -199,6 +242,18 @@ export class ChatPanel extends SignalWatcher(
} }
}; };
private readonly rebindSession = async () => {
if (!this.session) {
return;
}
if (this.session.docId !== this.doc.id) {
await this.updateSession({
sessionId: this.session.id,
docId: this.doc.id,
});
}
};
private readonly initPanel = async () => { private readonly initPanel = async () => {
try { try {
if (!this.isSidebarOpen.value) { if (!this.isSidebarOpen.value) {
@@ -218,13 +273,21 @@ export class ChatPanel extends SignalWatcher(
this.hasPinned = false; this.hasPinned = false;
}; };
private readonly updateEmbeddingProgress = ( private readonly onEmbeddingProgressChange = (
count: Record<ContextEmbedStatus, number> count: Record<ContextEmbedStatus, number>
) => { ) => {
const total = count.finished + count.processing + count.failed; const total = count.finished + count.processing + count.failed;
this.embeddingProgress = [count.finished, total]; this.embeddingProgress = [count.finished, total];
}; };
private readonly onContextChange = async (
context: Partial<ChatContextValue>
) => {
if (context.status === 'success') {
await this.rebindSession();
}
};
private readonly openPlayground = () => { private readonly openPlayground = () => {
const playgroundContent = html` const playgroundContent = html`
<playground-content <playground-content
@@ -291,35 +354,12 @@ export class ChatPanel extends SignalWatcher(
const style = styleMap({ const style = styleMap({
padding: width > 540 ? '8px 24px 0 24px' : '8px 12px 0 12px', padding: width > 540 ? '8px 24px 0 24px' : '8px 12px 0 12px',
}); });
const [done, total] = this.embeddingProgress;
const isEmbedding = total > 0 && done < total;
const title = html`
<div class="chat-panel-title-text">
${isEmbedding
? html`<span data-testid="chat-panel-embedding-progress"
>Embedding ${done}/${total}</span
>`
: 'AFFiNE AI'}
</div>
${this.playgroundConfig.visible.value
? html`
<div class="chat-panel-playground" @click=${this.openPlayground}>
${CenterPeekIcon()}
</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}> const left = html`<div class="chat-panel-container" style=${style}>
${keyed( ${keyed(
this.hasPinned ? this.session?.id : this.doc.id, this.hasPinned ? this.session?.id : this.doc.id,
html`<ai-chat-content html`<ai-chat-content
.chatTitle=${title} .chatTitle=${this.chatTitle}
.host=${this.host} .host=${this.host}
.session=${this.session} .session=${this.session}
.createSession=${this.createSession} .createSession=${this.createSession}
@@ -332,7 +372,8 @@ export class ChatPanel extends SignalWatcher(
.extensions=${this.extensions} .extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService} .affineFeatureFlagService=${this.affineFeatureFlagService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService} .affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.updateEmbeddingProgress=${this.updateEmbeddingProgress} .onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth} .width=${this.sidebarWidth}
></ai-chat-content>` ></ai-chat-content>`
)} )}

View File

@@ -75,7 +75,7 @@ export class AIChatComposer extends SignalWatcher(
accessor updateContext!: (context: Partial<AIChatInputContext>) => void; accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
@property({ attribute: false }) @property({ attribute: false })
accessor updateEmbeddingProgress!: ( accessor onEmbeddingProgressChange!: (
count: Record<ContextEmbedStatus, number> count: Record<ContextEmbedStatus, number>
) => void; ) => void;
@@ -382,7 +382,7 @@ export class AIChatComposer extends SignalWatcher(
return chip; return chip;
}); });
this.updateChips(nextChips); this.updateChips(nextChips);
this.updateEmbeddingProgress(count); this.onEmbeddingProgressChange(count);
if (count.processing === 0) { if (count.processing === 0) {
this._abortPoll(); this._abortPoll();
} }

View File

@@ -136,10 +136,13 @@ export class AIChatContent extends SignalWatcher(
accessor affineWorkspaceDialogService!: WorkspaceDialogService; accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false }) @property({ attribute: false })
accessor updateEmbeddingProgress!: ( accessor onEmbeddingProgressChange!: (
count: Record<ContextEmbedStatus, number> count: Record<ContextEmbedStatus, number>
) => void; ) => void;
@property({ attribute: false })
accessor onContextChange!: (context: Partial<ChatContextValue>) => void;
@property({ attribute: false }) @property({ attribute: false })
accessor width: Signal<number | undefined> | undefined; accessor width: Signal<number | undefined> | undefined;
@@ -177,14 +180,9 @@ 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( ? AIProvider.histories.chats(this.workspaceId, sessionId)
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)
@@ -245,6 +243,7 @@ export class AIChatContent extends SignalWatcher(
private readonly updateContext = (context: Partial<ChatContextValue>) => { private readonly updateContext = (context: Partial<ChatContextValue>) => {
this.chatContextValue = { ...this.chatContextValue, ...context }; this.chatContextValue = { ...this.chatContextValue, ...context };
this.onContextChange?.(context);
}; };
private readonly scrollToEnd = () => { private readonly scrollToEnd = () => {
@@ -389,7 +388,7 @@ export class AIChatContent extends SignalWatcher(
.createSession=${this.createSession} .createSession=${this.createSession}
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.updateEmbeddingProgress=${this.updateEmbeddingProgress} .onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.networkSearchConfig=${this.networkSearchConfig} .networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig} .reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig} .docDisplayConfig=${this.docDisplayConfig}

View File

@@ -1,5 +1,7 @@
import type { CopilotSessionType } from '@affine/graphql'; import type { CopilotSessionType } from '@affine/graphql';
import { createLitPortal } from '@blocksuite/affine/components/portal';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import { import {
@@ -8,18 +10,38 @@ import {
PinIcon, PinIcon,
PlusIcon, PlusIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property } from 'lit/decorators.js'; import { property, query } from 'lit/decorators.js';
import type { DocDisplayConfig } from '../ai-chat-chips';
export class AIChatToolbar extends WithDisposable(ShadowlessElement) { export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor session!: CopilotSessionType | null | undefined; accessor session!: CopilotSessionType | null | undefined;
@property({ attribute: false })
accessor workspaceId!: string;
@property({ attribute: false }) @property({ attribute: false })
accessor onNewSession!: () => void; accessor onNewSession!: () => void;
@property({ attribute: false }) @property({ attribute: false })
accessor onTogglePin!: () => void; accessor onTogglePin!: () => Promise<void>;
@property({ attribute: false })
accessor onOpenSession!: (sessionId: string) => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor notification: NotificationService | null | undefined;
@query('.history-button')
accessor historyButton!: HTMLDivElement;
private abortController: AbortController | null = null;
static override styles = css` static override styles = css`
.ai-chat-toolbar { .ai-chat-toolbar {
@@ -49,24 +71,100 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
`; `;
override render() { override render() {
const pined = this.session?.pinned; const pinned = this.session?.pinned;
return html` return html`
<div class="ai-chat-toolbar"> <div class="ai-chat-toolbar">
<div class="chat-toolbar-icon" @click=${this.onNewSession}> <div class="chat-toolbar-icon" @click=${this.onPlusClick}>
${PlusIcon()} ${PlusIcon()}
<affine-tooltip>New Chat</affine-tooltip> <affine-tooltip>New Chat</affine-tooltip>
</div> </div>
<div class="chat-toolbar-icon" @click=${this.onTogglePin}> <div class="chat-toolbar-icon" @click=${this.onTogglePin}>
${pined ? PinedIcon() : PinIcon()} ${pinned ? PinedIcon() : PinIcon()}
<affine-tooltip <affine-tooltip
>${pined ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip >${pinned ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip
> >
</div> </div>
<div class="chat-toolbar-icon"> <div
class="chat-toolbar-icon history-button"
@click=${this.toggleHistoryMenu}
>
${ArrowDownSmallIcon()} ${ArrowDownSmallIcon()}
<affine-tooltip>Chat History</affine-tooltip> <affine-tooltip>Chat History</affine-tooltip>
</div> </div>
</div> </div>
`; `;
} }
private readonly unpinConfirm = async () => {
if (this.session && this.session.pinned) {
try {
const confirm = this.notification
? await this.notification.confirm({
title: 'Switch Chat? Current chat is pinned',
message:
'Switching will unpinned the current chat. This will change the active chat panel, allowing you to navigate between different conversation histories.',
confirmText: 'Switch Chat',
cancelText: 'Cancel',
})
: true;
if (!confirm) {
return false;
}
await this.onTogglePin();
} catch {
this.notification?.toast('Failed to unpin the chat');
}
}
return true;
};
private readonly onPlusClick = async () => {
const confirm = await this.unpinConfirm();
if (confirm) {
this.onNewSession();
}
};
private readonly onSessionClick = async (sessionId: string) => {
const confirm = await this.unpinConfirm();
if (confirm) {
this.onOpenSession(sessionId);
}
};
private readonly toggleHistoryMenu = () => {
if (this.abortController) {
this.abortController.abort();
return;
}
this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () => {
this.abortController = null;
});
createLitPortal({
template: html`
<ai-session-history
.session=${this.session}
.workspaceId=${this.workspaceId}
.docDisplayConfig=${this.docDisplayConfig}
.onSessionClick=${this.onSessionClick}
.notification=${this.notification}
></ai-session-history>
`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: document.body,
computePosition: {
referenceElement: this.historyButton,
placement: 'bottom-end',
middleware: [offset({ crossAxis: 0, mainAxis: 5 }), flip()],
autoUpdate: { animationFrame: true },
},
abortController: this.abortController,
closeOnClickAway: true,
});
};
} }

View File

@@ -0,0 +1,232 @@
import type { CopilotSessionType } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { NotificationService } from '@blocksuite/affine/shared/services';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { css, html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { AIProvider } from '../../provider';
import type { DocDisplayConfig } from '../ai-chat-chips';
interface GroupedSessions {
today: BlockSuitePresets.AIRecentSession[];
last7Days: BlockSuitePresets.AIRecentSession[];
last30Days: BlockSuitePresets.AIRecentSession[];
older: BlockSuitePresets.AIRecentSession[];
}
export class AISessionHistory extends WithDisposable(ShadowlessElement) {
static override styles = css`
.ai-session-history {
width: 316px;
max-height: 344px;
padding: 12px 8px;
overflow-y: auto;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
background: ${unsafeCSSVarV2('layer/background/primary')};
border-radius: 4px;
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
.ai-session-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-session-group-title {
font-size: 12px;
font-weight: 400;
line-height: 20px;
height: 20px;
color: ${unsafeCSSVarV2('text/secondary')};
}
.ai-session-item {
display: flex;
height: 24px;
padding: 2px 4px;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.ai-session-item:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
border-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.ai-session-title {
font-size: 12px;
font-weight: 400;
line-height: 20px;
color: ${unsafeCSSVarV2('text/primary')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-session-doc {
display: flex;
width: 120px;
padding: 0px 4px;
align-items: center;
gap: 4px;
flex-shrink: 0;
cursor: pointer;
svg {
width: 16px;
height: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.doc-title {
font-size: 12px;
font-weight: 400;
line-height: 20px;
color: ${unsafeCSSVarV2('text/secondary')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
${scrollbarStyle('.ai-session-history')}
`;
@property({ attribute: false })
accessor session!: CopilotSessionType | null | undefined;
@property({ attribute: false })
accessor workspaceId!: string;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor onSessionClick!: (sessionId: string) => void;
@property({ attribute: false })
accessor notification: NotificationService | null | undefined;
@state()
private accessor sessions: BlockSuitePresets.AIRecentSession[] = [];
private groupSessionsByTime(
sessions: BlockSuitePresets.AIRecentSession[]
): GroupedSessions {
const now = new Date();
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
const last7DaysStart = new Date(
todayStart.getTime() - 6 * 24 * 60 * 60 * 1000
);
const last30DaysStart = new Date(
todayStart.getTime() - 29 * 24 * 60 * 60 * 1000
);
const grouped: GroupedSessions = {
today: [],
last7Days: [],
last30Days: [],
older: [],
};
sessions.forEach(session => {
const updatedAt = new Date(session.updatedAt);
if (updatedAt >= todayStart) {
grouped.today.push(session);
} else if (updatedAt >= last7DaysStart) {
grouped.last7Days.push(session);
} else if (updatedAt >= last30DaysStart) {
grouped.last30Days.push(session);
} else {
grouped.older.push(session);
}
});
// Sort each group by updatedAt in descending order (newest first)
(Object.keys(grouped) as Array<keyof GroupedSessions>).forEach(key => {
grouped[key].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
});
return grouped;
}
private async getRecentSessions() {
const limit = 50;
const sessions = await AIProvider.session?.getRecentSessions(
this.workspaceId,
limit
);
if (sessions) {
this.sessions = sessions;
}
}
override connectedCallback() {
super.connectedCallback();
this.getRecentSessions().catch(console.error);
}
private renderSessionGroup(
title: string,
sessions: BlockSuitePresets.AIRecentSession[]
) {
if (sessions.length === 0) {
return nothing;
}
return html`
<div class="ai-session-group">
<div class="ai-session-group-title">${title}</div>
${sessions.map(session => {
return html`
<div
class="ai-session-item"
@click=${() => this.onSessionClick(session.sessionId)}
>
<div class="ai-session-title">${session.sessionId}</div>
${session.docId ? this.renderSessionDoc(session.docId) : nothing}
</div>
`;
})}
</div>
`;
}
private renderSessionDoc(docId: string) {
const getIcon = this.docDisplayConfig.getIcon(docId);
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
return html`<div class="ai-session-doc">
${docIcon}
<span class="doc-title">${this.docDisplayConfig.getTitle(docId)}</span>
</div>`;
}
override render() {
if (this.sessions.length === 0) {
return nothing;
}
const groupedSessions = this.groupSessionsByTime(this.sessions);
return html`
<div class="ai-session-history">
${this.renderSessionGroup('Today', groupedSessions.today)}
${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)}
${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)}
${this.renderSessionGroup('Older', groupedSessions.older)}
</div>
`;
}
}

View File

@@ -1 +1,2 @@
export * from './ai-chat-toolbar'; export * from './ai-chat-toolbar';
export * from './ai-session-history';

View File

@@ -1,8 +1,7 @@
import type { CopilotSessionType } from '@affine/graphql'; import type { CopilotSessionType } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import { NotificationProvider } from '@blocksuite/affine/shared/services'; import { type NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
import { css, html } from 'lit'; import { css, html } from 'lit';
@@ -19,7 +18,7 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
accessor session!: CopilotSessionType | null | undefined; accessor session!: CopilotSessionType | null | undefined;
@property({ attribute: false }) @property({ attribute: false })
accessor host: EditorHost | null | undefined; accessor notification: NotificationService | null | undefined;
@property({ attribute: false }) @property({ attribute: false })
accessor doc!: Store; accessor doc!: Store;
@@ -52,10 +51,9 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
return; return;
} }
const sessionId = this.session.id; const sessionId = this.session.id;
const notification = this.host?.std.getOptional(NotificationProvider);
try { try {
const confirm = notification const confirm = this.notification
? await notification.confirm({ ? await this.notification.confirm({
title: 'Clear History', title: 'Clear History',
message: 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.', '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.',
@@ -73,11 +71,11 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
this.doc.id, this.doc.id,
[...(sessionId ? [sessionId] : []), ...(actionIds || [])] [...(sessionId ? [sessionId] : []), ...(actionIds || [])]
); );
notification?.toast('History cleared'); this.notification?.toast('History cleared');
this.onHistoryCleared?.(); this.onHistoryCleared?.();
} }
} catch { } catch {
notification?.toast('Failed to clear history'); this.notification?.toast('Failed to clear history');
} }
}; };

View File

@@ -1,6 +1,7 @@
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 } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { NotificationProvider } from '@blocksuite/affine/shared/services';
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';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -229,7 +230,7 @@ export class PlaygroundChat extends SignalWatcher(
this._scrollToEnd(); this._scrollToEnd();
}; };
private readonly _updateEmbeddingProgress = ( private readonly onEmbeddingProgressChange = (
count: Record<ContextEmbedStatus, number> count: Record<ContextEmbedStatus, number>
) => { ) => {
const total = count.finished + count.processing + count.failed; const total = count.finished + count.processing + count.failed;
@@ -272,6 +273,7 @@ export class PlaygroundChat extends SignalWatcher(
override render() { override render() {
const [done, total] = this.embeddingProgress; const [done, total] = this.embeddingProgress;
const isEmbedding = total > 0 && done < total; const isEmbedding = total > 0 && done < total;
const notification = this.host.std.getOptional(NotificationProvider);
return html`<div class="chat-panel-container"> return html`<div class="chat-panel-container">
<div class="chat-panel-title"> <div class="chat-panel-title">
@@ -287,9 +289,9 @@ export class PlaygroundChat extends SignalWatcher(
<affine-tooltip>Add chat</affine-tooltip> <affine-tooltip>Add chat</affine-tooltip>
</div> </div>
<ai-history-clear <ai-history-clear
.host=${this.host}
.doc=${this.doc} .doc=${this.doc}
.session=${this.session} .session=${this.session}
.notification=${notification}
.onHistoryCleared=${this._updateHistory} .onHistoryCleared=${this._updateHistory}
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
></ai-history-clear> ></ai-history-clear>
@@ -318,7 +320,7 @@ export class PlaygroundChat extends SignalWatcher(
.createSession=${this._createSession} .createSession=${this._createSession}
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.updateEmbeddingProgress=${this._updateEmbeddingProgress} .onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.networkSearchConfig=${this.networkSearchConfig} .networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig} .reasoningConfig=${this.reasoningConfig}
.playgroundConfig=${this.playgroundConfig} .playgroundConfig=${this.playgroundConfig}

View File

@@ -38,7 +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 { AIChatToolbar, AISessionHistory } 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';
@@ -115,6 +115,7 @@ export function registerAIEffects() {
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-toolbar', AIChatToolbar);
customElements.define('ai-session-history', AISessionHistory);
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

@@ -10,6 +10,7 @@ import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
import { ConnectorMode } from '@blocksuite/affine/model'; import { ConnectorMode } from '@blocksuite/affine/model';
import { import {
DocModeProvider, DocModeProvider,
NotificationProvider,
TelemetryProvider, TelemetryProvider,
} from '@blocksuite/affine/shared/services'; } from '@blocksuite/affine/shared/services';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
@@ -311,7 +312,7 @@ export class AIChatBlockPeekView extends LitElement {
this.chatContext = { ...this.chatContext, ...context }; this.chatContext = { ...this.chatContext, ...context };
}; };
private readonly _updateEmbeddingProgress = ( private readonly onEmbeddingProgressChange = (
count: Record<ContextEmbedStatus, number> count: Record<ContextEmbedStatus, number>
) => { ) => {
const total = count.finished + count.processing + count.failed; const total = count.finished + count.processing + count.failed;
@@ -565,15 +566,16 @@ export class AIChatBlockPeekView extends LitElement {
} = this; } = this;
const { messages: currentChatMessages } = chatContext; const { messages: currentChatMessages } = chatContext;
const notification = this.host.std.getOptional(NotificationProvider);
return html`<div class="ai-chat-block-peek-view-container"> return html`<div class="ai-chat-block-peek-view-container">
<div class="history-clear-container"> <div class="history-clear-container">
<ai-history-clear <ai-history-clear
.host=${this.host}
.doc=${this.host.store} .doc=${this.host.store}
.session=${this.forkSession} .session=${this.forkSession}
.onHistoryCleared=${this._onHistoryCleared} .onHistoryCleared=${this._onHistoryCleared}
.chatContextValue=${chatContext} .chatContextValue=${chatContext}
.notification=${notification}
></ai-history-clear> ></ai-history-clear>
</div> </div>
<div class="ai-chat-messages-container"> <div class="ai-chat-messages-container">
@@ -595,7 +597,7 @@ export class AIChatBlockPeekView extends LitElement {
.createSession=${this.createForkSession} .createSession=${this.createForkSession}
.chatContextValue=${chatContext} .chatContextValue=${chatContext}
.updateContext=${updateContext} .updateContext=${updateContext}
.updateEmbeddingProgress=${this._updateEmbeddingProgress} .onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.networkSearchConfig=${networkSearchConfig} .networkSearchConfig=${networkSearchConfig}
.docDisplayConfig=${this.docDisplayConfig} .docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig} .searchMenuConfig=${this.searchMenuConfig}

View File

@@ -11,6 +11,7 @@ import {
forkCopilotSessionMutation, forkCopilotSessionMutation,
getCopilotHistoriesQuery, getCopilotHistoriesQuery,
getCopilotHistoryIdsQuery, getCopilotHistoryIdsQuery,
getCopilotRecentSessionsQuery,
getCopilotSessionQuery, getCopilotSessionQuery,
getCopilotSessionsQuery, getCopilotSessionsQuery,
getWorkspaceEmbeddingStatusQuery, getWorkspaceEmbeddingStatusQuery,
@@ -179,6 +180,21 @@ export class CopilotClient {
} }
} }
async getRecentSessions(workspaceId: string, limit?: number) {
try {
const res = await this.gql({
query: getCopilotRecentSessionsQuery,
variables: {
workspaceId,
limit,
},
});
return res.currentUser?.copilot?.histories;
} catch (err) {
throw resolveError(err);
}
}
async getHistories( async getHistories(
workspaceId: string, workspaceId: string,
docId?: string, docId?: string,

View File

@@ -588,6 +588,9 @@ Could you make a new website based on these notes and send back just the html fi
) => { ) => {
return client.getSessions(workspaceId, docId, options); return client.getSessions(workspaceId, docId, options);
}, },
getRecentSessions: async (workspaceId: string, limit?: number) => {
return client.getRecentSessions(workspaceId, limit);
},
updateSession: async (options: UpdateChatSessionInput) => { updateSession: async (options: UpdateChatSessionInput) => {
return client.updateSession(options); return client.updateSession(options);
}, },

View File

@@ -195,8 +195,8 @@ export const Component = () => {
setCurrentSession(null); setCurrentSession(null);
chatContent?.reset(); chatContent?.reset();
}; };
tool.onTogglePin = () => { tool.onTogglePin = async () => {
togglePin().catch(console.error); await togglePin();
}; };
// mount // mount
chatToolContainerRef.current.append(tool); chatToolContainerRef.current.append(tool);