refactor(core): lazy create copilot session and context (#10170)

This commit is contained in:
akumatus
2025-02-14 14:48:46 +00:00
parent 5a42edf076
commit 631c8b8145
11 changed files with 179 additions and 170 deletions

View File

@@ -83,7 +83,7 @@ export class ChatActionList extends LitElement {
accessor content: string = ''; accessor content: string = '';
@property({ attribute: false }) @property({ attribute: false })
accessor chatSessionId: string | undefined = undefined; accessor getSessionId!: () => Promise<string | undefined>;
@property({ attribute: false }) @property({ attribute: false })
accessor messageId: string | undefined = undefined; accessor messageId: string | undefined = undefined;
@@ -100,7 +100,7 @@ export class ChatActionList extends LitElement {
return nothing; return nothing;
} }
const { host, content, chatSessionId, messageId, layoutDirection } = this; const { host, content, messageId, layoutDirection } = this;
const classes = classMap({ const classes = classMap({
'actions-container': true, 'actions-container': true,
horizontal: layoutDirection === 'horizontal', horizontal: layoutDirection === 'horizontal',
@@ -138,11 +138,12 @@ export class ChatActionList extends LitElement {
blocks: this._currentBlockSelections, blocks: this._currentBlockSelections,
images: this._currentImageSelections, images: this._currentImageSelections,
}; };
const sessionId = await this.getSessionId();
const success = await action.handler( const success = await action.handler(
host, host,
content, content,
currentSelections, currentSelections,
chatSessionId, sessionId,
messageId messageId
); );
if (success) { if (success) {

View File

@@ -115,7 +115,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
accessor content!: string; accessor content!: string;
@property({ attribute: false }) @property({ attribute: false })
accessor chatSessionId: string | undefined = undefined; accessor getSessionId!: () => Promise<string | undefined>;
@property({ attribute: false }) @property({ attribute: false })
accessor messageId: string | undefined = undefined; accessor messageId: string | undefined = undefined;
@@ -162,7 +162,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
} }
override render() { override render() {
const { host, content, isLast, messageId, chatSessionId, actions } = this; const { host, content, isLast, messageId, actions } = this;
return html`<style> return html`<style>
.copy-more { .copy-more {
margin-top: ${this.withMargin ? '8px' : '0px'}; margin-top: ${this.withMargin ? '8px' : '0px'};
@@ -217,11 +217,12 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
}; };
return html`<div return html`<div
@click=${async () => { @click=${async () => {
const sessionId = await this.getSessionId();
const success = await action.handler( const success = await action.handler(
host, host,
content, content,
currentSelections, currentSelections,
chatSessionId, sessionId,
messageId messageId
); );

View File

@@ -47,7 +47,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
accessor chatContextValue!: ChatContextValue; accessor chatContextValue!: ChatContextValue;
@property({ attribute: false }) @property({ attribute: false })
accessor chatContextId!: string | undefined; accessor getContextId!: () => Promise<string | undefined>;
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void; accessor updateContext!: (context: Partial<ChatContextValue>) => void;
@@ -196,34 +196,36 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
}; };
private readonly _addToContext = async (chip: ChatChip) => { private readonly _addToContext = async (chip: ChatChip) => {
if (!AIProvider.context || !this.chatContextId) { const contextId = await this.getContextId();
if (!contextId || !AIProvider.context) {
return; return;
} }
if (isDocChip(chip)) { if (isDocChip(chip)) {
await AIProvider.context.addContextDoc({ await AIProvider.context.addContextDoc({
contextId: this.chatContextId, contextId,
docId: chip.docId, docId: chip.docId,
}); });
} else { } else {
await AIProvider.context.addContextFile({ await AIProvider.context.addContextFile({
contextId: this.chatContextId, contextId,
fileId: chip.fileId, fileId: chip.fileId,
}); });
} }
}; };
private readonly _removeFromContext = async (chip: ChatChip) => { private readonly _removeFromContext = async (chip: ChatChip) => {
if (!AIProvider.context || !this.chatContextId) { const contextId = await this.getContextId();
if (!contextId || !AIProvider.context) {
return; return;
} }
if (isDocChip(chip)) { if (isDocChip(chip)) {
await AIProvider.context.removeContextDoc({ await AIProvider.context.removeContextDoc({
contextId: this.chatContextId, contextId,
docId: chip.docId, docId: chip.docId,
}); });
} else { } else {
await AIProvider.context.removeContextFile({ await AIProvider.context.removeContextFile({
contextId: this.chatContextId, contextId,
fileId: chip.fileId, fileId: chip.fileId,
}); });
} }

View File

@@ -260,7 +260,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
accessor chatContextValue!: ChatContextValue; accessor chatContextValue!: ChatContextValue;
@property({ attribute: false }) @property({ attribute: false })
accessor chatSessionId!: string | undefined; accessor getSessionId!: () => Promise<string | undefined>;
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void; accessor updateContext!: (context: Partial<ChatContextValue>) => void;
@@ -300,11 +300,9 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
private async _updatePromptName() { private async _updatePromptName() {
if (this._lastPromptName !== this._promptName) { if (this._lastPromptName !== this._promptName) {
this._lastPromptName = this._promptName; this._lastPromptName = this._promptName;
if (this.chatSessionId) { const sessionId = await this.getSessionId();
await AIProvider.session?.updateSession( if (sessionId) {
this.chatSessionId, await AIProvider.session?.updateSession(sessionId, this._promptName);
this._promptName
);
} }
} }
} }
@@ -559,6 +557,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
if (status === 'loading' || status === 'transmitting') return; if (status === 'loading' || status === 'transmitting') return;
if (!text) return; if (!text) return;
try {
const { images } = this.chatContextValue; const { images } = this.chatContextValue;
const { doc } = this.host; const { doc } = this.host;
@@ -570,8 +569,6 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
markdown: '', markdown: '',
}); });
await this._updatePromptName();
const attachments = await Promise.all( const attachments = await Promise.all(
images?.map(image => readBlobAsURL(image)) images?.map(image => readBlobAsURL(image))
); );
@@ -596,8 +593,10 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
], ],
}); });
try { await this._updatePromptName();
const abortController = new AbortController(); const abortController = new AbortController();
const sessionId = await this.getSessionId();
const docs: DocContext[] = chips const docs: DocContext[] = chips
.filter(isDocChip) .filter(isDocChip)
.filter(chip => !!chip.markdown?.value && chip.state === 'success') .filter(chip => !!chip.markdown?.value && chip.state === 'success')
@@ -606,7 +605,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
markdown: chip.markdown?.value || '', markdown: chip.markdown?.value || '',
})); }));
const stream = AIProvider.actions.chat?.({ const stream = AIProvider.actions.chat?.({
sessionId: this.chatSessionId, sessionId,
input: userInput, input: userInput,
docs: docs, docs: docs,
docId: doc.id, docId: doc.id,
@@ -638,7 +637,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
const historyIds = await AIProvider.histories?.ids( const historyIds = await AIProvider.histories?.ids(
doc.workspace.id, doc.workspace.id,
doc.id, doc.id,
{ sessionId: this.chatSessionId } { sessionId }
); );
if (!historyIds || !historyIds[0]) return; if (!historyIds || !historyIds[0]) return;
last.id = historyIds[0].messages.at(-1)?.id ?? ''; last.id = historyIds[0].messages.at(-1)?.id ?? '';

View File

@@ -134,7 +134,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
accessor chatContextValue!: ChatContextValue; accessor chatContextValue!: ChatContextValue;
@property({ attribute: false }) @property({ attribute: false })
accessor chatSessionId!: string | undefined; accessor getSessionId!: () => Promise<string | undefined>;
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void; accessor updateContext!: (context: Partial<ChatContextValue>) => void;
@@ -415,7 +415,8 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
retry = async () => { retry = async () => {
const { doc } = this.host; const { doc } = this.host;
try { try {
if (!this.chatSessionId) return; const sessionId = await this.getSessionId();
if (!sessionId) return;
const abortController = new AbortController(); const abortController = new AbortController();
const items = [...this.chatContextValue.items]; const items = [...this.chatContextValue.items];
@@ -427,7 +428,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
this.updateContext({ items, status: 'loading', error: null }); this.updateContext({ items, status: 'loading', error: null });
const stream = AIProvider.actions.chat?.({ const stream = AIProvider.actions.chat?.({
sessionId: this.chatSessionId, sessionId,
retry: true, retry: true,
docId: doc.id, docId: doc.id,
workspaceId: doc.workspace.id, workspaceId: doc.workspace.id,
@@ -482,7 +483,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
.actions=${actions} .actions=${actions}
.content=${content} .content=${content}
.isLast=${isLast} .isLast=${isLast}
.chatSessionId=${this.chatSessionId} .getSessionId=${this.getSessionId}
.messageId=${messageId} .messageId=${messageId}
.withMargin=${true} .withMargin=${true}
.retry=${() => this.retry()} .retry=${() => this.retry()}
@@ -492,7 +493,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
.actions=${actions} .actions=${actions}
.host=${host} .host=${host}
.content=${content} .content=${content}
.chatSessionId=${this.chatSessionId} .getSessionId=${this.getSessionId}
.messageId=${messageId ?? undefined} .messageId=${messageId ?? undefined}
.withMargin=${true} .withMargin=${true}
></chat-action-list>` ></chat-action-list>`

View File

@@ -63,8 +63,8 @@ export class ChatPanelDocChip extends SignalWatcher(
super.updated(changedProperties); super.updated(changedProperties);
if ( if (
changedProperties.has('chip') && changedProperties.has('chip') &&
changedProperties.get('chip')?.state === 'candidate' && this.chip.state === 'processing' &&
this.chip.state === 'processing' !this.chip.markdown
) { ) {
this.processDocChip().catch(console.error); this.processDocChip().catch(console.error);
} }

View File

@@ -36,6 +36,17 @@ import type {
import type { ChatPanelMessages } from './chat-panel-messages'; import type { ChatPanelMessages } from './chat-panel-messages';
import { isDocContext } from './components/utils'; import { isDocContext } from './components/utils';
const DEFAULT_CHAT_CONTEXT_VALUE: ChatContextValue = {
quote: '',
images: [],
abortController: null,
items: [],
chips: [],
status: 'idle',
error: null,
markdown: '',
};
export class ChatPanel extends WithDisposable(ShadowlessElement) { export class ChatPanel extends WithDisposable(ShadowlessElement) {
static override styles = css` static override styles = css`
chat-panel { chat-panel {
@@ -156,14 +167,22 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
}; };
private readonly _updateChips = async () => { private readonly _updateChips = async () => {
if (!this._chatSessionId || !this._chatContextId) return;
const candidateChip: DocChip = { const candidateChip: DocChip = {
docId: this.doc.id, docId: this.doc.id,
state: 'candidate', state: 'candidate',
}; };
// context not initialized, show candidate chip
if (!this._chatSessionId || !this._chatContextId) {
this.chatContextValue = {
...this.chatContextValue,
chips: [candidateChip],
};
return;
}
// context initialized, show the chips
let chips: (DocChip | FileChip)[] = []; let chips: (DocChip | FileChip)[] = [];
if (this._chatContextId) {
const { docs = [], files = [] } = const { docs = [], files = [] } =
(await AIProvider.context?.getContextDocsAndFiles( (await AIProvider.context?.getContextDocsAndFiles(
this.doc.workspace.id, this.doc.workspace.id,
@@ -191,14 +210,37 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
} }
return chip; return chip;
}); });
}
this.chatContextValue = { this.chatContextValue = {
...this.chatContextValue, ...this.chatContextValue,
chips: chips.length === 0 ? [candidateChip] : chips, chips: chips.length === 0 ? [candidateChip] : chips,
}; };
}; };
private readonly _getSessionId = async () => {
if (this._chatSessionId) {
return this._chatSessionId;
}
this._chatSessionId = await AIProvider.session?.createSession(
this.doc.workspace.id,
this.doc.id
);
return this._chatSessionId;
};
private readonly _getContextId = async () => {
if (this._chatContextId) {
return this._chatContextId;
}
const sessionId = await this._getSessionId();
if (sessionId) {
this._chatContextId = await AIProvider.context?.createContext(
this.doc.workspace.id,
sessionId
);
}
return this._chatContextId;
};
@property({ attribute: false }) @property({ attribute: false })
accessor host!: EditorHost; accessor host!: EditorHost;
@@ -221,20 +263,11 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
accessor isLoading = false; accessor isLoading = false;
@state() @state()
accessor chatContextValue: ChatContextValue = { accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
quote: '',
images: [],
abortController: null,
items: [],
chips: [],
status: 'idle',
error: null,
markdown: '',
};
private _chatSessionId: string | undefined; private _chatSessionId: string | null | undefined = null;
private _chatContextId: string | undefined; private _chatContextId: string | null | undefined = null;
private readonly _scrollToEnd = () => { private readonly _scrollToEnd = () => {
this._chatMessages.value?.scrollToEnd(); this._chatMessages.value?.scrollToEnd();
@@ -272,26 +305,29 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
const userId = (await AIProvider.userInfo)?.id; const userId = (await AIProvider.userInfo)?.id;
if (!userId) return; if (!userId) return;
try { const sessionIds = await AIProvider.session?.getSessionIds(
this._chatSessionId = await AIProvider.session?.createSession(
this.doc.workspace.id, this.doc.workspace.id,
this.doc.id this.doc.id
); );
if (sessionIds?.length) {
this._chatSessionId = sessionIds[0];
await this._updateHistory();
}
if (this._chatSessionId) { if (this._chatSessionId) {
this._chatContextId = await AIProvider.context?.createContext( this._chatContextId = await AIProvider.context?.getContextId(
this.doc.workspace.id, this.doc.workspace.id,
this._chatSessionId this._chatSessionId
); );
} }
} catch (e) {
console.error('init panel error', e);
}
await this._updateHistory();
await this._updateChips(); await this._updateChips();
}; };
protected override updated(_changedProperties: PropertyValues) { protected override updated(_changedProperties: PropertyValues) {
if (_changedProperties.has('doc')) { if (_changedProperties.has('doc')) {
this._chatSessionId = null;
this._chatContextId = null;
this.chatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
await this._initPanel(); await this._initPanel();
}); });
@@ -376,7 +412,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
<chat-panel-messages <chat-panel-messages
${ref(this._chatMessages)} ${ref(this._chatMessages)}
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.chatSessionId=${this._chatSessionId} .getSessionId=${this._getSessionId}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.host=${this.host} .host=${this.host}
.isLoading=${this.isLoading} .isLoading=${this.isLoading}
@@ -385,14 +421,14 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
<chat-panel-chips <chat-panel-chips
.host=${this.host} .host=${this.host}
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.chatContextId=${this._chatContextId} .getContextId=${this._getContextId}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.docDisplayConfig=${this.docDisplayConfig} .docDisplayConfig=${this.docDisplayConfig}
.docSearchMenuConfig=${this.docSearchMenuConfig} .docSearchMenuConfig=${this.docSearchMenuConfig}
></chat-panel-chips> ></chat-panel-chips>
<chat-panel-input <chat-panel-input
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.chatSessionId=${this._chatSessionId} .getSessionId=${this._getSessionId}
.networkSearchConfig=${this.networkSearchConfig} .networkSearchConfig=${this.networkSearchConfig}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.host=${this.host} .host=${this.host}

View File

@@ -131,6 +131,10 @@ export class AIChatBlockPeekView extends LitElement {
}); });
}; };
private readonly _getSessionId = async () => {
return this.chatContext.currentSessionId ?? undefined;
};
/** /**
* Create a new AI chat block based on the current session and history messages * Create a new AI chat block based on the current session and history messages
*/ */
@@ -408,7 +412,7 @@ export class AIChatBlockPeekView extends LitElement {
.actions=${actions} .actions=${actions}
.content=${message.content} .content=${message.content}
.isLast=${isLastReply} .isLast=${isLastReply}
.chatSessionId=${this.chatContext.currentSessionId ?? undefined} .getSessionId=${this._getSessionId}
.messageId=${message.id ?? undefined} .messageId=${message.id ?? undefined}
.retry=${() => this.retry()} .retry=${() => this.retry()}
></chat-copy-more>` ></chat-copy-more>`
@@ -418,7 +422,7 @@ export class AIChatBlockPeekView extends LitElement {
.host=${host} .host=${host}
.actions=${actions} .actions=${actions}
.content=${message.content} .content=${message.content}
.chatSessionId=${this.chatContext.currentSessionId ?? undefined} .getSessionId=${this._getSessionId}
.messageId=${message.id ?? undefined} .messageId=${message.id ?? undefined}
.layoutDirection=${'horizontal'} .layoutDirection=${'horizontal'}
></chat-action-list>` ></chat-action-list>`

View File

@@ -30,30 +30,6 @@ export type ToImageOptions = TextToTextOptions & {
seed?: string; seed?: string;
}; };
export async function createChatSession({
client,
workspaceId,
docId,
promptName = 'Chat With AFFiNE AI',
}: {
client: CopilotClient;
workspaceId: string;
docId: string;
promptName?: string;
}) {
const sessionId = await client.createSession({
workspaceId,
docId,
promptName,
});
// always update the prompt name
await client.updateSession({
sessionId,
promptName,
});
return sessionId;
}
async function resizeImage(blob: Blob | File): Promise<Blob | null> { async function resizeImage(blob: Blob | File): Promise<Blob | null> {
let src = ''; let src = '';
try { try {
@@ -90,7 +66,7 @@ async function createSessionMessage({
client, client,
docId, docId,
workspaceId, workspaceId,
promptName, promptName = 'Chat With AFFiNE AI',
content, content,
sessionId: providedSessionId, sessionId: providedSessionId,
attachments, attachments,
@@ -102,11 +78,10 @@ async function createSessionMessage({
} }
const hasAttachments = attachments && attachments.length > 0; const hasAttachments = attachments && attachments.length > 0;
const sessionId = await (providedSessionId ?? const sessionId = await (providedSessionId ??
createChatSession({ client.createSession({
client,
workspaceId, workspaceId,
docId, docId,
promptName: promptName as string, promptName,
})); }));
const options: Parameters<CopilotClient['createMessage']>[0] = { const options: Parameters<CopilotClient['createMessage']>[0] = {

View File

@@ -11,7 +11,7 @@ import { z } from 'zod';
import type { CopilotClient } from './copilot-client'; import type { CopilotClient } from './copilot-client';
import type { PromptKey } from './prompt'; import type { PromptKey } from './prompt';
import { createChatSession, textToText, toImage } from './request'; import { textToText, toImage } from './request';
import { setupTracker } from './tracker'; import { setupTracker } from './tracker';
const filterStyleToPromptName = new Map( const filterStyleToPromptName = new Map(
@@ -37,13 +37,6 @@ export function setupAIProvider(
) { ) {
//#region actions //#region actions
AIProvider.provide('chat', options => { AIProvider.provide('chat', options => {
const sessionId =
options.sessionId ??
createChatSession({
client,
workspaceId: options.workspaceId,
docId: options.docId,
});
const { input, docs, ...rest } = options; const { input, docs, ...rest } = options;
const params = docs?.length const params = docs?.length
? { ? {
@@ -58,7 +51,6 @@ export function setupAIProvider(
...rest, ...rest,
client, client,
content: input, content: input,
sessionId,
params, params,
}); });
}); });
@@ -408,10 +400,9 @@ Could you make a new website based on these notes and send back just the html fi
createSession: async ( createSession: async (
workspaceId: string, workspaceId: string,
docId: string, docId: string,
promptName?: string promptName = 'Chat With AFFiNE AI'
) => { ) => {
return createChatSession({ return client.createSession({
client,
workspaceId, workspaceId,
docId, docId,
promptName, promptName,

View File

@@ -104,12 +104,14 @@ const clearChat = async (page: Page) => {
}; };
const collectChat = async (page: Page) => { const collectChat = async (page: Page) => {
await page.waitForTimeout(ONE_SECOND);
const chatPanel = await page.waitForSelector('.chat-panel-messages'); const chatPanel = await page.waitForSelector('.chat-panel-messages');
if (await chatPanel.$('.chat-panel-messages-placeholder')) { if (await chatPanel.$('.chat-panel-messages-placeholder')) {
return []; return [];
} }
// wait ai response // wait ai response
await page.waitForSelector('.chat-panel-messages .message chat-copy-more'); await page.waitForSelector('.chat-panel-messages .message chat-copy-more');
await page.waitForTimeout(ONE_SECOND);
const lastMessage = await chatPanel.$$('.message').then(m => m[m.length - 1]); const lastMessage = await chatPanel.$$('.message').then(m => m[m.length - 1]);
await lastMessage.waitForSelector('chat-copy-more'); await lastMessage.waitForSelector('chat-copy-more');
await page.waitForTimeout(ONE_SECOND); await page.waitForTimeout(ONE_SECOND);
@@ -489,7 +491,6 @@ test.describe('chat panel', () => {
await page.getByTestId('chat-network-search').click(); await page.getByTestId('chat-network-search').click();
await typeChatSequentially(page, 'What is the weather in Shanghai today?'); await typeChatSequentially(page, 'What is the weather in Shanghai today?');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(3000);
let history = await collectChat(page); let history = await collectChat(page);
expect(history[0]).toEqual({ expect(history[0]).toEqual({
name: 'You', name: 'You',
@@ -505,7 +506,6 @@ test.describe('chat panel', () => {
await page.getByTestId('chat-network-search').click(); await page.getByTestId('chat-network-search').click();
await typeChatSequentially(page, 'What is the weather in Shanghai today?'); await typeChatSequentially(page, 'What is the weather in Shanghai today?');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(3000);
history = await collectChat(page); history = await collectChat(page);
expect(history[0]).toEqual({ expect(history[0]).toEqual({
name: 'You', name: 'You',
@@ -859,7 +859,6 @@ test.describe('chat with doc', () => {
await typeChatSequentially(page, 'What is AFFiNE AI?'); await typeChatSequentially(page, 'What is AFFiNE AI?');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(3000);
const history = await collectChat(page); const history = await collectChat(page);
expect(history[0]).toEqual({ expect(history[0]).toEqual({
name: 'You', name: 'You',