mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
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:
@@ -5,6 +5,7 @@ import type {
|
||||
CopilotContextCategory,
|
||||
CopilotContextDoc,
|
||||
CopilotContextFile,
|
||||
CopilotHistories,
|
||||
CopilotSessionType,
|
||||
getCopilotHistoriesQuery,
|
||||
QueryChatSessionsInput,
|
||||
@@ -387,6 +388,8 @@ declare global {
|
||||
reuseLatestChat?: boolean;
|
||||
}
|
||||
|
||||
type AIRecentSession = Omit<CopilotHistories, 'messages'>;
|
||||
|
||||
interface AISessionService {
|
||||
createSession: (options: AICreateSessionOptions) => Promise<string>;
|
||||
getSessions: (
|
||||
@@ -394,6 +397,10 @@ declare global {
|
||||
docId?: string,
|
||||
options?: QueryChatSessionsInput
|
||||
) => Promise<CopilotSessionType[] | undefined>;
|
||||
getRecentSessions: (
|
||||
workspaceId: string,
|
||||
limit?: number
|
||||
) => Promise<AIRecentSession[] | undefined>;
|
||||
getSession: (
|
||||
workspaceId: string,
|
||||
sessionId: string
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
UpdateChatSessionInput,
|
||||
} from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { NotificationProvider } from '@blocksuite/affine/shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
@@ -27,6 +28,7 @@ import type {
|
||||
DocDisplayConfig,
|
||||
SearchMenuConfig,
|
||||
} from '../components/ai-chat-chips';
|
||||
import type { ChatContextValue } from '../components/ai-chat-content';
|
||||
import type {
|
||||
AINetworkSearchConfig,
|
||||
AIPlaygroundConfig,
|
||||
@@ -125,6 +127,38 @@ export class ChatPanel extends SignalWatcher(
|
||||
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 () => {
|
||||
if (!AIProvider.session) {
|
||||
return;
|
||||
@@ -132,7 +166,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
const pinSessions = await AIProvider.session.getSessions(
|
||||
this.doc.workspace.id,
|
||||
undefined,
|
||||
{ pinned: true }
|
||||
{ pinned: true, limit: 1 }
|
||||
);
|
||||
if (Array.isArray(pinSessions) && pinSessions[0]) {
|
||||
this.session = pinSessions[0];
|
||||
@@ -140,7 +174,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
const docSessions = await AIProvider.session.getSessions(
|
||||
this.doc.workspace.id,
|
||||
this.doc.id,
|
||||
{ action: false, fork: false }
|
||||
{ action: false, fork: false, limit: 1 }
|
||||
);
|
||||
// the first item is the latest session
|
||||
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 () => {
|
||||
const pinned = !this.session?.pinned;
|
||||
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 () => {
|
||||
try {
|
||||
if (!this.isSidebarOpen.value) {
|
||||
@@ -218,13 +273,21 @@ export class ChatPanel extends SignalWatcher(
|
||||
this.hasPinned = false;
|
||||
};
|
||||
|
||||
private readonly updateEmbeddingProgress = (
|
||||
private readonly onEmbeddingProgressChange = (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => {
|
||||
const total = count.finished + count.processing + count.failed;
|
||||
this.embeddingProgress = [count.finished, total];
|
||||
};
|
||||
|
||||
private readonly onContextChange = async (
|
||||
context: Partial<ChatContextValue>
|
||||
) => {
|
||||
if (context.status === 'success') {
|
||||
await this.rebindSession();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly openPlayground = () => {
|
||||
const playgroundContent = html`
|
||||
<playground-content
|
||||
@@ -291,35 +354,12 @@ export class ChatPanel extends SignalWatcher(
|
||||
const style = styleMap({
|
||||
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}>
|
||||
${keyed(
|
||||
this.hasPinned ? this.session?.id : this.doc.id,
|
||||
html`<ai-chat-content
|
||||
.chatTitle=${title}
|
||||
.chatTitle=${this.chatTitle}
|
||||
.host=${this.host}
|
||||
.session=${this.session}
|
||||
.createSession=${this.createSession}
|
||||
@@ -332,7 +372,8 @@ export class ChatPanel extends SignalWatcher(
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.updateEmbeddingProgress=${this.updateEmbeddingProgress}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.onContextChange=${this.onContextChange}
|
||||
.width=${this.sidebarWidth}
|
||||
></ai-chat-content>`
|
||||
)}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateEmbeddingProgress!: (
|
||||
accessor onEmbeddingProgressChange!: (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => void;
|
||||
|
||||
@@ -382,7 +382,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
return chip;
|
||||
});
|
||||
this.updateChips(nextChips);
|
||||
this.updateEmbeddingProgress(count);
|
||||
this.onEmbeddingProgressChange(count);
|
||||
if (count.processing === 0) {
|
||||
this._abortPoll();
|
||||
}
|
||||
|
||||
@@ -136,10 +136,13 @@ export class AIChatContent extends SignalWatcher(
|
||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateEmbeddingProgress!: (
|
||||
accessor onEmbeddingProgressChange!: (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onContextChange!: (context: Partial<ChatContextValue>) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: Signal<number | undefined> | undefined;
|
||||
|
||||
@@ -177,14 +180,9 @@ 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,
|
||||
pinned ? undefined : this.docId
|
||||
)
|
||||
? AIProvider.histories.chats(this.workspaceId, sessionId)
|
||||
: Promise.resolve([]),
|
||||
this.docId
|
||||
? AIProvider.histories.actions(this.workspaceId, this.docId)
|
||||
@@ -245,6 +243,7 @@ export class AIChatContent extends SignalWatcher(
|
||||
|
||||
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
||||
this.chatContextValue = { ...this.chatContextValue, ...context };
|
||||
this.onContextChange?.(context);
|
||||
};
|
||||
|
||||
private readonly scrollToEnd = () => {
|
||||
@@ -389,7 +388,7 @@ export class AIChatContent extends SignalWatcher(
|
||||
.createSession=${this.createSession}
|
||||
.chatContextValue=${this.chatContextValue}
|
||||
.updateContext=${this.updateContext}
|
||||
.updateEmbeddingProgress=${this.updateEmbeddingProgress}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.networkSearchConfig=${this.networkSearchConfig}
|
||||
.reasoningConfig=${this.reasoningConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CopilotSessionType } from '@affine/graphql';
|
||||
import { createLitPortal } from '@blocksuite/affine/components/portal';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { NotificationService } from '@blocksuite/affine/shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import {
|
||||
@@ -8,18 +10,38 @@ import {
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
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) {
|
||||
@property({ attribute: false })
|
||||
accessor session!: CopilotSessionType | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor workspaceId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onNewSession!: () => void;
|
||||
|
||||
@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`
|
||||
.ai-chat-toolbar {
|
||||
@@ -49,24 +71,100 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const pined = this.session?.pinned;
|
||||
const pinned = this.session?.pinned;
|
||||
return html`
|
||||
<div class="ai-chat-toolbar">
|
||||
<div class="chat-toolbar-icon" @click=${this.onNewSession}>
|
||||
<div class="chat-toolbar-icon" @click=${this.onPlusClick}>
|
||||
${PlusIcon()}
|
||||
<affine-tooltip>New Chat</affine-tooltip>
|
||||
</div>
|
||||
<div class="chat-toolbar-icon" @click=${this.onTogglePin}>
|
||||
${pined ? PinedIcon() : PinIcon()}
|
||||
${pinned ? PinedIcon() : PinIcon()}
|
||||
<affine-tooltip
|
||||
>${pined ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip
|
||||
>${pinned ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip
|
||||
>
|
||||
</div>
|
||||
<div class="chat-toolbar-icon">
|
||||
<div
|
||||
class="chat-toolbar-icon history-button"
|
||||
@click=${this.toggleHistoryMenu}
|
||||
>
|
||||
${ArrowDownSmallIcon()}
|
||||
<affine-tooltip>Chat History</affine-tooltip>
|
||||
</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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './ai-chat-toolbar';
|
||||
export * from './ai-session-history';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { CopilotSessionType } from '@affine/graphql';
|
||||
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 type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import { css, html } from 'lit';
|
||||
@@ -19,7 +18,7 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
||||
accessor session!: CopilotSessionType | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host: EditorHost | null | undefined;
|
||||
accessor notification: NotificationService | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Store;
|
||||
@@ -52,10 +51,9 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
||||
return;
|
||||
}
|
||||
const sessionId = this.session.id;
|
||||
const notification = this.host?.std.getOptional(NotificationProvider);
|
||||
try {
|
||||
const confirm = notification
|
||||
? await notification.confirm({
|
||||
const confirm = this.notification
|
||||
? await this.notification.confirm({
|
||||
title: 'Clear History',
|
||||
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.',
|
||||
@@ -73,11 +71,11 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
||||
this.doc.id,
|
||||
[...(sessionId ? [sessionId] : []), ...(actionIds || [])]
|
||||
);
|
||||
notification?.toast('History cleared');
|
||||
this.notification?.toast('History cleared');
|
||||
this.onHistoryCleared?.();
|
||||
}
|
||||
} catch {
|
||||
notification?.toast('Failed to clear history');
|
||||
this.notification?.toast('Failed to clear history');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { NotificationProvider } from '@blocksuite/affine/shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
@@ -229,7 +230,7 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
this._scrollToEnd();
|
||||
};
|
||||
|
||||
private readonly _updateEmbeddingProgress = (
|
||||
private readonly onEmbeddingProgressChange = (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => {
|
||||
const total = count.finished + count.processing + count.failed;
|
||||
@@ -272,6 +273,7 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
override render() {
|
||||
const [done, total] = this.embeddingProgress;
|
||||
const isEmbedding = total > 0 && done < total;
|
||||
const notification = this.host.std.getOptional(NotificationProvider);
|
||||
|
||||
return html`<div class="chat-panel-container">
|
||||
<div class="chat-panel-title">
|
||||
@@ -287,9 +289,9 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
<affine-tooltip>Add chat</affine-tooltip>
|
||||
</div>
|
||||
<ai-history-clear
|
||||
.host=${this.host}
|
||||
.doc=${this.doc}
|
||||
.session=${this.session}
|
||||
.notification=${notification}
|
||||
.onHistoryCleared=${this._updateHistory}
|
||||
.chatContextValue=${this.chatContextValue}
|
||||
></ai-history-clear>
|
||||
@@ -318,7 +320,7 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
.createSession=${this._createSession}
|
||||
.chatContextValue=${this.chatContextValue}
|
||||
.updateContext=${this.updateContext}
|
||||
.updateEmbeddingProgress=${this._updateEmbeddingProgress}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.networkSearchConfig=${this.networkSearchConfig}
|
||||
.reasoningConfig=${this.reasoningConfig}
|
||||
.playgroundConfig=${this.playgroundConfig}
|
||||
|
||||
@@ -38,7 +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 { AIChatToolbar, AISessionHistory } 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';
|
||||
@@ -115,6 +115,7 @@ export function registerAIEffects() {
|
||||
customElements.define('ai-loading', AILoading);
|
||||
customElements.define('ai-chat-content', AIChatContent);
|
||||
customElements.define('ai-chat-toolbar', AIChatToolbar);
|
||||
customElements.define('ai-session-history', AISessionHistory);
|
||||
customElements.define('ai-chat-messages', AIChatMessages);
|
||||
customElements.define('chat-panel', ChatPanel);
|
||||
customElements.define('ai-chat-input', AIChatInput);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||
import { ConnectorMode } from '@blocksuite/affine/model';
|
||||
import {
|
||||
DocModeProvider,
|
||||
NotificationProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -311,7 +312,7 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
this.chatContext = { ...this.chatContext, ...context };
|
||||
};
|
||||
|
||||
private readonly _updateEmbeddingProgress = (
|
||||
private readonly onEmbeddingProgressChange = (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => {
|
||||
const total = count.finished + count.processing + count.failed;
|
||||
@@ -565,15 +566,16 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
} = this;
|
||||
|
||||
const { messages: currentChatMessages } = chatContext;
|
||||
const notification = this.host.std.getOptional(NotificationProvider);
|
||||
|
||||
return html`<div class="ai-chat-block-peek-view-container">
|
||||
<div class="history-clear-container">
|
||||
<ai-history-clear
|
||||
.host=${this.host}
|
||||
.doc=${this.host.store}
|
||||
.session=${this.forkSession}
|
||||
.onHistoryCleared=${this._onHistoryCleared}
|
||||
.chatContextValue=${chatContext}
|
||||
.notification=${notification}
|
||||
></ai-history-clear>
|
||||
</div>
|
||||
<div class="ai-chat-messages-container">
|
||||
@@ -595,7 +597,7 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
.createSession=${this.createForkSession}
|
||||
.chatContextValue=${chatContext}
|
||||
.updateContext=${updateContext}
|
||||
.updateEmbeddingProgress=${this._updateEmbeddingProgress}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.networkSearchConfig=${networkSearchConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
forkCopilotSessionMutation,
|
||||
getCopilotHistoriesQuery,
|
||||
getCopilotHistoryIdsQuery,
|
||||
getCopilotRecentSessionsQuery,
|
||||
getCopilotSessionQuery,
|
||||
getCopilotSessionsQuery,
|
||||
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(
|
||||
workspaceId: string,
|
||||
docId?: string,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
getRecentSessions: async (workspaceId: string, limit?: number) => {
|
||||
return client.getRecentSessions(workspaceId, limit);
|
||||
},
|
||||
updateSession: async (options: UpdateChatSessionInput) => {
|
||||
return client.updateSession(options);
|
||||
},
|
||||
|
||||
@@ -195,8 +195,8 @@ export const Component = () => {
|
||||
setCurrentSession(null);
|
||||
chatContent?.reset();
|
||||
};
|
||||
tool.onTogglePin = () => {
|
||||
togglePin().catch(console.error);
|
||||
tool.onTogglePin = async () => {
|
||||
await togglePin();
|
||||
};
|
||||
// mount
|
||||
chatToolContainerRef.current.append(tool);
|
||||
|
||||
Reference in New Issue
Block a user