feat(core): poll context docs and files embedding status (#10843)

Close [BS-2791](https://linear.app/affine-design/issue/BS-2791).

### What Changed?
- Add status filed to `CopilotContextDoc` to querying document embedding processing progress.
- Change `ChipState` from `success` to `finished` to better align with backend server status.
- Add `pollContextDocsAndFiles` API for embedding status polling.

### About Polling
- Set the minimum interval to 1 second and the maximum interval to 30 seconds
- Use exponential backoff and increase the interval by 50% after each poll
- Make sure the interval does not exceed the maximum
This commit is contained in:
akumatus
2025-03-14 08:44:55 +00:00
parent 114e89961f
commit 1546b76337
8 changed files with 170 additions and 41 deletions

View File

@@ -242,6 +242,11 @@ declare global {
): AIActionTextResponse<T>; ): AIActionTextResponse<T>;
} }
type AIDocsAndFilesContext = {
docs: CopilotContextDoc[];
files: CopilotContextFile[];
};
interface AIContextService { interface AIContextService {
createContext: ( createContext: (
workspaceId: string, workspaceId: string,
@@ -274,13 +279,14 @@ declare global {
workspaceId: string, workspaceId: string,
sessionId: string, sessionId: string,
contextId: string contextId: string
) => Promise< ) => Promise<AIDocsAndFilesContext | undefined>;
| { pollContextDocsAndFiles: (
docs: Array<CopilotContextDoc>; workspaceId: string,
files: Array<CopilotContextFile>; sessionId: string,
} contextId: string,
| undefined onPoll: (result: AIDocsAndFilesContext | undefined) => void,
>; abortSignal: AbortSignal
) => Promise<void>;
matchContext: ( matchContext: (
contextId: string, contextId: string,
content: string, content: string,

View File

@@ -70,13 +70,13 @@ export type ChatBlockMessage = ChatMessage & {
avatarUrl?: string; avatarUrl?: string;
}; };
export type ChipState = 'candidate' | 'processing' | 'success' | 'failed'; export type ChipState = 'candidate' | 'processing' | 'finished' | 'failed';
export interface BaseChip { export interface BaseChip {
/** /**
* candidate: the chip is a candidate for the chat * candidate: the chip is a candidate for the chat
* processing: the chip is processing * processing: the chip is processing
* success: the chip is successfully processed * finished: the chip is successfully processed
* failed: the chip is failed to process * failed: the chip is failed to process
*/ */
state: ChipState; state: ChipState;

View File

@@ -68,6 +68,9 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void; accessor updateContext!: (context: Partial<ChatContextValue>) => void;
@property({ attribute: false })
accessor pollContextDocsAndFiles!: () => void;
@property({ attribute: false }) @property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig; accessor docDisplayConfig!: DocDisplayConfig;
@@ -201,6 +204,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
await this._removeFromContext(chip); await this._removeFromContext(chip);
} }
await this._addToContext(chip); await this._addToContext(chip);
this.pollContextDocsAndFiles();
}; };
private readonly _updateChip = ( private readonly _updateChip = (
@@ -229,19 +233,24 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
private readonly _removeChip = async (chip: ChatChip) => { private readonly _removeChip = async (chip: ChatChip) => {
if (isDocChip(chip)) { if (isDocChip(chip)) {
await this._removeFromContext(chip); const removed = await this._removeFromContext(chip);
this.updateContext({ if (removed) {
chips: this.chatContextValue.chips.filter(item => { this.updateContext({
return !isDocChip(item) || item.docId !== chip.docId; chips: this.chatContextValue.chips.filter(item => {
}), return !isDocChip(item) || item.docId !== chip.docId;
}); }),
} else { });
await this._removeFromContext(chip); }
this.updateContext({ }
chips: this.chatContextValue.chips.filter(item => { if (isFileChip(chip)) {
return !isFileChip(item) || item.file !== chip.file; const removed = await this._removeFromContext(chip);
}), if (removed) {
}); this.updateContext({
chips: this.chatContextValue.chips.filter(item => {
return !isFileChip(item) || item.file !== chip.file;
}),
});
}
} }
}; };
@@ -255,7 +264,8 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
contextId, contextId,
docId: chip.docId, docId: chip.docId,
}); });
} else { }
if (isFileChip(chip)) {
try { try {
const blobId = await this.host.doc.blobSync.set(chip.file); const blobId = await this.host.doc.blobSync.set(chip.file);
const contextFile = await AIProvider.context.addContextFile(chip.file, { const contextFile = await AIProvider.context.addContextFile(chip.file, {
@@ -263,7 +273,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
blobId, blobId,
}); });
this._updateChip(chip, { this._updateChip(chip, {
state: 'success', state: contextFile.status,
blobId: contextFile.blobId, blobId: contextFile.blobId,
fileId: contextFile.id, fileId: contextFile.id,
}); });
@@ -276,22 +286,26 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
} }
}; };
private readonly _removeFromContext = async (chip: ChatChip) => { private readonly _removeFromContext = async (
chip: ChatChip
): Promise<boolean> => {
const contextId = await this.getContextId(); const contextId = await this.getContextId();
if (!contextId || !AIProvider.context) { if (!contextId || !AIProvider.context) {
return; return false;
} }
if (isDocChip(chip)) { if (isDocChip(chip)) {
await AIProvider.context.removeContextDoc({ return await AIProvider.context.removeContextDoc({
contextId, contextId,
docId: chip.docId, docId: chip.docId,
}); });
} else if (isFileChip(chip) && chip.fileId) { }
await AIProvider.context.removeContextFile({ if (isFileChip(chip) && chip.fileId) {
return await AIProvider.context.removeContextFile({
contextId, contextId,
fileId: chip.fileId, fileId: chip.fileId,
}); });
} }
return true;
}; };
private readonly _checkTokenLimit = ( private readonly _checkTokenLimit = (
@@ -305,7 +319,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
if (chip.docId === newChip.docId) { if (chip.docId === newChip.docId) {
return acc + newTokenCount; return acc + newTokenCount;
} }
if (chip.markdown?.value && chip.state === 'success') { if (chip.markdown?.value && chip.state === 'finished') {
const tokenCount = const tokenCount =
chip.tokenCount ?? estimateTokenCount(chip.markdown.value); chip.tokenCount ?? estimateTokenCount(chip.markdown.value);
return acc + tokenCount; return acc + tokenCount;

View File

@@ -226,7 +226,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
private get _isNetworkDisabled() { private get _isNetworkDisabled() {
return ( return (
!!this.chatContextValue.images.length || !!this.chatContextValue.images.length ||
!!this.chatContextValue.chips.filter(chip => chip.state === 'success') !!this.chatContextValue.chips.filter(chip => chip.state === 'finished')
.length .length
); );
} }
@@ -561,7 +561,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
: []; : [];
const contexts = this.chatContextValue.chips.reduce( const contexts = this.chatContextValue.chips.reduce(
(acc, chip, index) => { (acc, chip, index) => {
if (chip.state !== 'success') { if (chip.state !== 'finished') {
return acc; return acc;
} }
if (isDocChip(chip) && !!chip.markdown?.value) { if (isDocChip(chip) && !!chip.markdown?.value) {

View File

@@ -112,7 +112,7 @@ export class ChatPanelDocChip extends SignalWatcher(
const markdown = this.chip.markdown ?? new Signal<string>(''); const markdown = this.chip.markdown ?? new Signal<string>('');
markdown.value = value; markdown.value = value;
this.updateChip(this.chip, { this.updateChip(this.chip, {
state: 'success', state: 'finished',
markdown, markdown,
tokenCount, tokenCount,
}); });

View File

@@ -1,6 +1,7 @@
import './chat-panel-input'; import './chat-panel-input';
import './chat-panel-messages'; import './chat-panel-messages';
import type { CopilotContextDoc, CopilotContextFile } from '@affine/graphql';
import type { EditorHost } from '@blocksuite/affine/block-std'; import type { EditorHost } from '@blocksuite/affine/block-std';
import { ShadowlessElement } from '@blocksuite/affine/block-std'; import { ShadowlessElement } from '@blocksuite/affine/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
@@ -35,7 +36,7 @@ import type {
FileChip, FileChip,
} from './chat-context'; } from './chat-context';
import type { ChatPanelMessages } from './chat-panel-messages'; import type { ChatPanelMessages } from './chat-panel-messages';
import { isDocContext } from './components/utils'; import { isDocChip, isDocContext } from './components/utils';
const DEFAULT_CHAT_CONTEXT_VALUE: ChatContextValue = { const DEFAULT_CHAT_CONTEXT_VALUE: ChatContextValue = {
quote: '', quote: '',
@@ -174,7 +175,7 @@ export class ChatPanel extends SignalWatcher(
this._scrollToEnd(); this._scrollToEnd();
}; };
private readonly _updateChips = async () => { private readonly _initChips = async () => {
// context not initialized, show candidate chip // context not initialized, show candidate chip
if (!this._chatSessionId || !this._chatContextId) { if (!this._chatSessionId || !this._chatContextId) {
return; return;
@@ -191,12 +192,16 @@ export class ChatPanel extends SignalWatcher(
(a, b) => (a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
); );
let allDone = true;
const chips: ChatChip[] = await Promise.all( const chips: ChatChip[] = await Promise.all(
list.map(async item => { list.map(async item => {
if (item.status === 'processing') {
allDone = false;
}
if (isDocContext(item)) { if (isDocContext(item)) {
return { return {
docId: item.id, docId: item.id,
state: 'processing', state: item.status || 'processing',
} as DocChip; } as DocChip;
} }
const file = await this.host.doc.blobSync.get(item.blobId); const file = await this.host.doc.blobSync.get(item.blobId);
@@ -212,7 +217,7 @@ export class ChatPanel extends SignalWatcher(
file: new File([file], item.name), file: new File([file], item.name),
blobId: item.blobId, blobId: item.blobId,
fileId: item.id, fileId: item.id,
state: item.status === 'finished' ? 'success' : item.status, state: item.status,
tooltip: item.error, tooltip: item.error,
} as FileChip; } as FileChip;
} }
@@ -223,6 +228,10 @@ export class ChatPanel extends SignalWatcher(
...this.chatContextValue, ...this.chatContextValue,
chips, chips,
}; };
if (!allDone) {
await this._pollContextDocsAndFiles();
}
}; };
private readonly _getSessionId = async () => { private readonly _getSessionId = async () => {
@@ -285,6 +294,8 @@ export class ChatPanel extends SignalWatcher(
private _width: Signal<number | undefined> = signal(undefined); private _width: Signal<number | undefined> = signal(undefined);
private _pollAbortController: AbortController | null = null;
private readonly _scrollToEnd = () => { private readonly _scrollToEnd = () => {
if (!this._wheelTriggered) { if (!this._wheelTriggered) {
this._chatMessages.value?.scrollToEnd(); this._chatMessages.value?.scrollToEnd();
@@ -345,14 +356,79 @@ export class ChatPanel extends SignalWatcher(
this._chatSessionId this._chatSessionId
); );
} }
await this._updateChips(); await this._initChips();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}; };
private readonly _pollContextDocsAndFiles = async () => {
if (!this._chatSessionId || !this._chatContextId || !AIProvider.context) {
return;
}
if (this._pollAbortController) {
// already polling, return
return;
}
this._pollAbortController = new AbortController();
await AIProvider.context.pollContextDocsAndFiles(
this.doc.workspace.id,
this._chatSessionId,
this._chatContextId,
this._onPoll,
this._pollAbortController.signal
);
};
private readonly _onPoll = (
result?: BlockSuitePresets.AIDocsAndFilesContext
) => {
if (!result) {
this._abortPoll();
return;
}
const { docs = [], files = [] } = result;
const hashMap = new Map<string, CopilotContextDoc | CopilotContextFile>();
let allDone = true;
docs.forEach(doc => {
hashMap.set(doc.id, doc);
if (doc.status === 'processing') {
allDone = false;
}
});
files.forEach(file => {
hashMap.set(file.id, file);
if (file.status === 'processing') {
allDone = false;
}
});
const nextChips = this.chatContextValue.chips.map(chip => {
const id = isDocChip(chip) ? chip.docId : chip.fileId;
const item = id && hashMap.get(id);
if (item && item.status) {
return {
...chip,
state: item.status,
};
}
return chip;
});
this.updateContext({
chips: nextChips,
});
if (allDone) {
this._abortPoll();
}
};
private readonly _abortPoll = () => {
this._pollAbortController?.abort();
this._pollAbortController = null;
};
protected override updated(_changedProperties: PropertyValues) { protected override updated(_changedProperties: PropertyValues) {
if (_changedProperties.has('doc')) { if (_changedProperties.has('doc')) {
this._abortPoll();
this._chatSessionId = null; this._chatSessionId = null;
this._chatContextId = null; this._chatContextId = null;
this.chatContextValue = DEFAULT_CHAT_CONTEXT_VALUE; this.chatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
@@ -494,6 +570,7 @@ export class ChatPanel extends SignalWatcher(
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.getContextId=${this._getContextId} .getContextId=${this._getContextId}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.pollContextDocsAndFiles=${this._pollContextDocsAndFiles}
.docDisplayConfig=${this.docDisplayConfig} .docDisplayConfig=${this.docDisplayConfig}
.docSearchMenuConfig=${this.docSearchMenuConfig} .docSearchMenuConfig=${this.docSearchMenuConfig}
></chat-panel-chips> ></chat-panel-chips>

View File

@@ -451,6 +451,38 @@ Could you make a new website based on these notes and send back just the html fi
) => { ) => {
return client.getContextDocsAndFiles(workspaceId, sessionId, contextId); return client.getContextDocsAndFiles(workspaceId, sessionId, contextId);
}, },
pollContextDocsAndFiles: async (
workspaceId: string,
sessionId: string,
contextId: string,
onPoll: (
result: BlockSuitePresets.AIDocsAndFilesContext | undefined
) => void,
abortSignal: AbortSignal
) => {
const poll = async () => {
const result = await client.getContextDocsAndFiles(
workspaceId,
sessionId,
contextId
);
onPoll(result);
};
let attempts = 0;
const MIN_INTERVAL = 1000;
const MAX_INTERVAL = 30 * 1000;
while (!abortSignal.aborted) {
await poll();
const interval = Math.min(
MIN_INTERVAL * Math.pow(1.5, attempts),
MAX_INTERVAL
);
attempts++;
await new Promise(resolve => setTimeout(resolve, interval));
}
},
matchContext: async ( matchContext: async (
contextId: string, contextId: string,
content: string, content: string,

View File

@@ -1026,7 +1026,7 @@ test.describe('chat with doc', () => {
expect(await chipTitle.textContent()).toBe('Untitled'); expect(await chipTitle.textContent()).toBe('Untitled');
let chip = await page.getByTestId('chat-panel-chip'); let chip = await page.getByTestId('chat-panel-chip');
// oxlint-disable-next-line unicorn/prefer-dom-node-dataset // oxlint-disable-next-line unicorn/prefer-dom-node-dataset
expect(await chip.getAttribute('data-state')).toBe('success'); expect(await chip.getAttribute('data-state')).toBe('finished');
const editorTitle = await page.locator('doc-title .inline-editor').nth(0); const editorTitle = await page.locator('doc-title .inline-editor').nth(0);
await editorTitle.pressSequentially('AFFiNE AI', { await editorTitle.pressSequentially('AFFiNE AI', {
@@ -1048,7 +1048,7 @@ test.describe('chat with doc', () => {
expect(await chipTitle.textContent()).toBe('AFFiNE AI'); expect(await chipTitle.textContent()).toBe('AFFiNE AI');
// oxlint-disable-next-line unicorn/prefer-dom-node-dataset // oxlint-disable-next-line unicorn/prefer-dom-node-dataset
expect(await chip.getAttribute('data-state')).toBe('success'); expect(await chip.getAttribute('data-state')).toBe('finished');
await typeChatSequentially(page, 'What is AFiAI?'); await typeChatSequentially(page, 'What is AFiAI?');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
@@ -1071,6 +1071,6 @@ test.describe('chat with doc', () => {
expect(await chipTitle.textContent()).toBe('AFFiNE AI'); expect(await chipTitle.textContent()).toBe('AFFiNE AI');
const chip2 = await page.getByTestId('chat-panel-chip'); const chip2 = await page.getByTestId('chat-panel-chip');
// oxlint-disable-next-line unicorn/prefer-dom-node-dataset // oxlint-disable-next-line unicorn/prefer-dom-node-dataset
expect(await chip2.getAttribute('data-state')).toBe('success'); expect(await chip2.getAttribute('data-state')).toBe('finished');
}); });
}); });