From aa4874a55c64c05a9e4cf59ae99b75a95e5a0342 Mon Sep 17 00:00:00 2001 From: EYHN Date: Wed, 25 Jun 2025 10:55:27 +0800 Subject: [PATCH] feat(core): use cloud indexer for search (#12899) ## Summary by CodeRabbit - **New Features** - Added enhanced error handling and user-friendly error messages in quick search and document search menus. - Introduced loading state indicators for search operations. - Quick Search now provides explicit error feedback in the UI. - **Improvements** - Search and aggregation operations can now prefer remote or local indexers based on user or system preference. - Streamlined indexer logic for more consistent and reliable search experiences. - Refined error handling in messaging and synchronization layers for improved stability. - Enhanced error object handling in messaging for clearer error propagation. - Updated cloud workspace storage to always use IndexedDB locally and CloudIndexer remotely. - Shifted indexer operations to use synchronized indexer layer for better consistency. - Simplified indexer client by consolidating storage and sync layers. - Improved error propagation in messaging handlers by wrapping error objects. - Updated document search to prioritize remote indexer results by default. - **Bug Fixes** - Improved robustness of search features by handling errors gracefully and preventing potential runtime issues. - **Style** - Added new styles for displaying error messages in search interfaces. - **Chores** - Removed the obsolete "Enable Cloud Indexer" feature flag; cloud indexer behavior is now always enabled where applicable. --- .../infra/src/op/__tests__/consumer.spec.ts | 5 +- packages/common/infra/src/op/client.ts | 4 +- packages/common/infra/src/op/consumer.ts | 21 ++- .../common/nbstore/src/frontend/indexer.ts | 47 +++-- packages/common/nbstore/src/sync/index.ts | 20 ++- .../common/nbstore/src/sync/indexer/index.ts | 163 +++++++++++++++++- packages/common/nbstore/src/worker/client.ts | 75 ++------ .../common/nbstore/src/worker/consumer.ts | 18 +- packages/common/nbstore/src/worker/ops.ts | 66 +++---- .../docs-search/services/docs-search.ts | 1 + .../core/src/modules/feature-flag/constant.ts | 8 - .../quicksearch/entities/quick-search.ts | 5 + .../src/modules/quicksearch/impls/docs.ts | 20 ++- .../core/src/modules/quicksearch/index.ts | 1 + .../providers/quick-search-provider.ts | 2 +- .../src/modules/quicksearch/views/cmdk.css.ts | 7 + .../src/modules/quicksearch/views/cmdk.tsx | 3 + .../modules/quicksearch/views/container.tsx | 3 + .../src/modules/search-menu/services/index.ts | 30 +++- .../modules/workspace-engine/impls/cloud.ts | 43 ++--- tests/kit/src/utils/cloud.ts | 2 + 21 files changed, 366 insertions(+), 178 deletions(-) diff --git a/packages/common/infra/src/op/__tests__/consumer.spec.ts b/packages/common/infra/src/op/__tests__/consumer.spec.ts index 0c779059b0..628407bb29 100644 --- a/packages/common/infra/src/op/__tests__/consumer.spec.ts +++ b/packages/common/infra/src/op/__tests__/consumer.spec.ts @@ -43,7 +43,10 @@ describe('op consumer', () => { expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(` [ { - "error": [Error: Handler for operation [add] is not registered.], + "error": { + "message": "Handler for operation [add] is not registered.", + "name": "Error", + }, "id": "add:1", "type": "return", }, diff --git a/packages/common/infra/src/op/client.ts b/packages/common/infra/src/op/client.ts index 8034225a2f..efc3bb54b8 100644 --- a/packages/common/infra/src/op/client.ts +++ b/packages/common/infra/src/op/client.ts @@ -61,7 +61,7 @@ export class OpClient extends AutoMessageHandler { } if ('error' in msg) { - pending.reject(msg.error); + pending.reject(Object.assign(new Error(), msg.error)); } else { pending.resolve(msg.data); } @@ -86,7 +86,7 @@ export class OpClient extends AutoMessageHandler { return; } - ob.error(msg.error); + ob.error(Object.assign(new Error(), msg.error)); }; private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] = diff --git a/packages/common/infra/src/op/consumer.ts b/packages/common/infra/src/op/consumer.ts index 9d420e3116..06bd38284c 100644 --- a/packages/common/infra/src/op/consumer.ts +++ b/packages/common/infra/src/op/consumer.ts @@ -1,4 +1,5 @@ import EventEmitter2 from 'eventemitter2'; +import { pick } from 'lodash-es'; import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs'; import { MANUALLY_STOP } from '../utils'; @@ -70,7 +71,15 @@ export class OpConsumer extends AutoMessageHandler { this.port.postMessage({ type: 'return', id: msg.id, - error: error as Error, + error: pick(error, [ + 'name', + 'message', + 'code', + 'type', + 'status', + 'data', + 'stacktrace', + ]), } satisfies ReturnMessage); }, complete: () => { @@ -100,7 +109,15 @@ export class OpConsumer extends AutoMessageHandler { this.port.postMessage({ type: 'error', id: msg.id, - error: error as Error, + error: pick(error, [ + 'name', + 'message', + 'code', + 'type', + 'status', + 'data', + 'stacktrace', + ]), } satisfies SubscriptionErrorMessage); }, complete: () => { diff --git a/packages/common/nbstore/src/frontend/indexer.ts b/packages/common/nbstore/src/frontend/indexer.ts index 0ba6ccd007..d0adcf4e31 100644 --- a/packages/common/nbstore/src/frontend/indexer.ts +++ b/packages/common/nbstore/src/frontend/indexer.ts @@ -1,20 +1,13 @@ -import { switchMap } from 'rxjs'; - import type { AggregateOptions, IndexerSchema, - IndexerStorage, Query, SearchOptions, } from '../storage'; -import type { IndexerSync } from '../sync/indexer'; -import { fromPromise } from '../utils/from-promise'; +import type { IndexerPreferOptions, IndexerSync } from '../sync/indexer'; export class IndexerFrontend { - constructor( - public readonly storage: IndexerStorage, - public readonly sync: IndexerSync - ) {} + constructor(public readonly sync: IndexerSync) {} get state$() { return this.sync.state$; @@ -27,47 +20,47 @@ export class IndexerFrontend { async search>( table: T, query: Query, - options?: O + options?: O & { prefer?: IndexerPreferOptions } ) { - await this.waitForConnected(); - return this.storage.search(table, query, options); + return this.sync.search(table, query, options); } async aggregate< T extends keyof IndexerSchema, const O extends AggregateOptions, - >(table: T, query: Query, field: keyof IndexerSchema[T], options?: O) { - await this.waitForConnected(); - return this.storage.aggregate(table, query, field, options); + >( + table: T, + query: Query, + field: keyof IndexerSchema[T], + options?: O & { prefer?: IndexerPreferOptions } + ) { + return this.sync.aggregate(table, query, field, options); } search$>( table: T, query: Query, - options?: O + options?: O & { prefer?: IndexerPreferOptions } ) { - return fromPromise(signal => this.waitForConnected(signal)).pipe( - switchMap(() => this.storage.search$(table, query, options)) - ); + return this.sync.search$(table, query, options); } aggregate$< T extends keyof IndexerSchema, const O extends AggregateOptions, - >(table: T, query: Query, field: keyof IndexerSchema[T], options?: O) { - return fromPromise(signal => this.waitForConnected(signal)).pipe( - switchMap(() => this.storage.aggregate$(table, query, field, options)) - ); + >( + table: T, + query: Query, + field: keyof IndexerSchema[T], + options?: O & { prefer?: IndexerPreferOptions } + ) { + return this.sync.aggregate$(table, query, field, options); } addPriority(docId: string, priority: number) { return this.sync.addPriority(docId, priority); } - private waitForConnected(signal?: AbortSignal) { - return this.storage.connection.waitForConnected(signal); - } - waitForCompleted(signal?: AbortSignal) { return this.sync.waitForCompleted(signal); } diff --git a/packages/common/nbstore/src/sync/index.ts b/packages/common/nbstore/src/sync/index.ts index c659c2ad30..030ada01f7 100644 --- a/packages/common/nbstore/src/sync/index.ts +++ b/packages/common/nbstore/src/sync/index.ts @@ -9,7 +9,11 @@ import type { PeerStorageOptions } from './types'; export type { BlobSyncState } from './blob'; export type { DocSyncDocState, DocSyncState } from './doc'; -export type { IndexerDocSyncState, IndexerSyncState } from './indexer'; +export type { + IndexerDocSyncState, + IndexerPreferOptions, + IndexerSyncState, +} from './indexer'; export interface SyncState { doc?: DocSyncState; @@ -65,7 +69,19 @@ export class Sync { ]) ), }); - this.indexer = new IndexerSyncImpl(doc, indexer, indexerSync); + this.indexer = new IndexerSyncImpl( + doc, + { + local: indexer, + remotes: Object.fromEntries( + Object.entries(storages.remotes).map(([peerId, remote]) => [ + peerId, + remote.get('indexer'), + ]) + ), + }, + indexerSync + ); this.state$ = this.doc.state$.pipe(map(doc => ({ doc }))); } diff --git a/packages/common/nbstore/src/sync/indexer/index.ts b/packages/common/nbstore/src/sync/indexer/index.ts index 8d3af17fe0..03a5471722 100644 --- a/packages/common/nbstore/src/sync/indexer/index.ts +++ b/packages/common/nbstore/src/sync/indexer/index.ts @@ -1,4 +1,5 @@ import { readAllDocsFromRootDoc } from '@affine/reader'; +import { omit } from 'lodash-es'; import { filter, first, @@ -7,21 +8,33 @@ import { ReplaySubject, share, Subject, + switchMap, throttleTime, } from 'rxjs'; import { applyUpdate, Doc as YDoc } from 'yjs'; import { + type AggregateOptions, + type AggregateResult, type DocStorage, IndexerDocument, + type IndexerSchema, type IndexerStorage, + type Query, + type SearchOptions, + type SearchResult, } from '../../storage'; +import { DummyIndexerStorage } from '../../storage/dummy/indexer'; import type { IndexerSyncStorage } from '../../storage/indexer-sync'; import { AsyncPriorityQueue } from '../../utils/async-priority-queue'; +import { fromPromise } from '../../utils/from-promise'; import { takeUntilAbort } from '../../utils/take-until-abort'; import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted'; +import type { PeerStorageOptions } from '../types'; import { crawlingDocData } from './crawler'; +export type IndexerPreferOptions = 'local' | 'remote'; + export interface IndexerSyncState { /** * Number of documents currently in the indexing queue @@ -59,6 +72,35 @@ export interface IndexerSync { addPriority(docId: string, priority: number): () => void; waitForCompleted(signal?: AbortSignal): Promise; waitForDocCompleted(docId: string, signal?: AbortSignal): Promise; + + search>( + table: T, + query: Query, + options?: O & { prefer?: IndexerPreferOptions } + ): Promise>; + + aggregate>( + table: T, + query: Query, + field: keyof IndexerSchema[T], + options?: O & { prefer?: IndexerPreferOptions } + ): Promise>; + + search$>( + table: T, + query: Query, + options?: O & { prefer?: IndexerPreferOptions } + ): Observable>; + + aggregate$< + T extends keyof IndexerSchema, + const O extends AggregateOptions, + >( + table: T, + query: Query, + field: keyof IndexerSchema[T], + options?: O & { prefer?: IndexerPreferOptions } + ): Observable>; } export class IndexerSyncImpl implements IndexerSync { @@ -70,6 +112,9 @@ export class IndexerSyncImpl implements IndexerSync { private readonly rootDocId = this.doc.spaceId; private readonly status = new IndexerSyncStatus(this.rootDocId); + private readonly indexer: IndexerStorage; + private readonly remote?: IndexerStorage; + state$ = this.status.state$.pipe( // throttle the state to 1 second to avoid spamming the UI throttleTime(1000, undefined, { @@ -106,9 +151,13 @@ export class IndexerSyncImpl implements IndexerSync { constructor( readonly doc: DocStorage, - readonly indexer: IndexerStorage, + readonly peers: PeerStorageOptions, readonly indexerSync: IndexerSyncStorage - ) {} + ) { + // sync feature only works on local indexer + this.indexer = this.peers.local; + this.remote = Object.values(this.peers.remotes).find(remote => !!remote); + } start() { if (this.abort) { @@ -439,6 +488,116 @@ export class IndexerSyncImpl implements IndexerSync { }) ); } + + async search>( + table: T, + query: Query, + options?: O & { prefer?: IndexerPreferOptions } + ): Promise> { + if ( + options?.prefer === 'remote' && + this.remote && + !(this.remote instanceof DummyIndexerStorage) + ) { + await this.remote.connection.waitForConnected(); + return await this.remote.search(table, query, omit(options, 'prefer')); + } else { + await this.indexer.connection.waitForConnected(); + return await this.indexer.search(table, query, omit(options, 'prefer')); + } + } + + async aggregate< + T extends keyof IndexerSchema, + const O extends AggregateOptions, + >( + table: T, + query: Query, + field: keyof IndexerSchema[T], + options?: O & { prefer?: IndexerPreferOptions } + ): Promise> { + if ( + options?.prefer === 'remote' && + this.remote && + !(this.remote instanceof DummyIndexerStorage) + ) { + await this.remote.connection.waitForConnected(); + return await this.remote.aggregate( + table, + query, + field, + omit(options, 'prefer') + ); + } else { + await this.indexer.connection.waitForConnected(); + return await this.indexer.aggregate( + table, + query, + field, + omit(options, 'prefer') + ); + } + } + + search$>( + table: T, + query: Query, + options?: O & { prefer?: IndexerPreferOptions } + ): Observable> { + if ( + options?.prefer === 'remote' && + this.remote && + !(this.remote instanceof DummyIndexerStorage) + ) { + const remote = this.remote; + return fromPromise(signal => + remote.connection.waitForConnected(signal) + ).pipe( + switchMap(() => remote.search$(table, query, omit(options, 'prefer'))) + ); + } else { + return fromPromise(signal => + this.indexer.connection.waitForConnected(signal) + ).pipe( + switchMap(() => + this.indexer.search$(table, query, omit(options, 'prefer')) + ) + ); + } + } + + aggregate$< + T extends keyof IndexerSchema, + const O extends AggregateOptions, + >( + table: T, + query: Query, + field: keyof IndexerSchema[T], + options?: O & { prefer?: IndexerPreferOptions } + ): Observable> { + if ( + options?.prefer === 'remote' && + this.remote && + !(this.remote instanceof DummyIndexerStorage) + ) { + const remote = this.remote; + return fromPromise(signal => + remote.connection.waitForConnected(signal) + ).pipe( + switchMap(() => + remote.aggregate$(table, query, field, omit(options, 'prefer')) + ) + ); + } else { + return fromPromise(signal => + this.indexer.connection.waitForConnected(signal) + ).pipe( + switchMap(() => + this.indexer.aggregate$(table, query, field, omit(options, 'prefer')) + ) + ); + } + } } class IndexerSyncStatus { diff --git a/packages/common/nbstore/src/worker/client.ts b/packages/common/nbstore/src/worker/client.ts index b793fcaff6..931db21a62 100644 --- a/packages/common/nbstore/src/worker/client.ts +++ b/packages/common/nbstore/src/worker/client.ts @@ -18,9 +18,7 @@ import { type DocRecord, type DocStorage, type DocUpdate, - type IndexerDocument, type IndexerSchema, - type IndexerStorage, type ListedBlobRecord, type Query, type SearchOptions, @@ -29,7 +27,7 @@ import { import type { AwarenessSync } from '../sync/awareness'; import type { BlobSync } from '../sync/blob'; import type { DocSync } from '../sync/doc'; -import type { IndexerSync } from '../sync/indexer'; +import type { IndexerPreferOptions, IndexerSync } from '../sync/indexer'; import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops'; export type { StoreInitOptions as WorkerInitOptions } from './ops'; @@ -100,12 +98,8 @@ export class StoreClient { this.docFrontend = new DocFrontend(this.docStorage, this.docSync); this.blobFrontend = new BlobFrontend(this.blobStorage, this.blobSync); this.awarenessFrontend = new AwarenessFrontend(this.awarenessSync); - this.indexerStorage = new WorkerIndexerStorage(this.client); this.indexerSync = new WorkerIndexerSync(this.client); - this.indexerFrontend = new IndexerFrontend( - this.indexerStorage, - this.indexerSync - ); + this.indexerFrontend = new IndexerFrontend(this.indexerSync); } private readonly docStorage: WorkerDocStorage; @@ -113,7 +107,6 @@ export class StoreClient { private readonly docSync: WorkerDocSync; private readonly blobSync: WorkerBlobSync; private readonly awarenessSync: WorkerAwarenessSync; - private readonly indexerStorage: WorkerIndexerStorage; private readonly indexerSync: WorkerIndexerSync; readonly docFrontend: DocFrontend; @@ -348,26 +341,23 @@ class WorkerAwarenessSync implements AwarenessSync { } } -class WorkerIndexerStorage implements IndexerStorage { +class WorkerIndexerSync implements IndexerSync { constructor(private readonly client: OpClient) {} - readonly storageType = 'indexer'; - readonly isReadonly = true; - connection = new WorkerIndexerConnection(this.client); search>( table: T, query: Query, - options?: O + options?: O & { prefer?: IndexerPreferOptions } ): Promise> { - return this.client.call('indexerStorage.search', { table, query, options }); + return this.client.call('indexerSync.search', { table, query, options }); } aggregate>( table: T, query: Query, field: keyof IndexerSchema[T], - options?: O + options?: O & { prefer?: IndexerPreferOptions } ): Promise> { - return this.client.call('indexerStorage.aggregate', { + return this.client.call('indexerSync.aggregate', { table, query, field: field as string, @@ -377,9 +367,9 @@ class WorkerIndexerStorage implements IndexerStorage { search$>( table: T, query: Query, - options?: O + options?: O & { prefer?: IndexerPreferOptions } ): Observable> { - return this.client.ob$('indexerStorage.subscribeSearch', { + return this.client.ob$('indexerSync.subscribeSearch', { table, query, options, @@ -392,59 +382,16 @@ class WorkerIndexerStorage implements IndexerStorage { table: T, query: Query, field: keyof IndexerSchema[T], - options?: O + options?: O & { prefer?: IndexerPreferOptions } ): Observable> { - return this.client.ob$('indexerStorage.subscribeAggregate', { + return this.client.ob$('indexerSync.subscribeAggregate', { table, query, field: field as string, options, }); } - deleteByQuery( - _table: T, - _query: Query - ): Promise { - throw new Error('Method not implemented.'); - } - insert( - _table: T, - _document: IndexerDocument - ): Promise { - throw new Error('Method not implemented.'); - } - delete(_table: T, _id: string): Promise { - throw new Error('Method not implemented.'); - } - update( - _table: T, - _document: IndexerDocument - ): Promise { - throw new Error('Method not implemented.'); - } - refresh(_table: T): Promise { - throw new Error('Method not implemented.'); - } -} -class WorkerIndexerConnection extends DummyConnection { - constructor(private readonly client: OpClient) { - super(); - } - - promise: Promise | undefined; - - override waitForConnected(): Promise { - if (this.promise) { - return this.promise; - } - this.promise = this.client.call('indexerStorage.waitForConnected'); - return this.promise; - } -} - -class WorkerIndexerSync implements IndexerSync { - constructor(private readonly client: OpClient) {} waitForCompleted(signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const abortListener = () => { diff --git a/packages/common/nbstore/src/worker/consumer.ts b/packages/common/nbstore/src/worker/consumer.ts index a0b197c804..741a35696d 100644 --- a/packages/common/nbstore/src/worker/consumer.ts +++ b/packages/common/nbstore/src/worker/consumer.ts @@ -265,16 +265,6 @@ class StoreConsumer { }), 'awarenessSync.collect': ({ collectId, awareness }) => collectJobs.get(collectId)?.(awareness), - 'indexerStorage.aggregate': ({ table, query, field, options }) => - this.indexerStorage.aggregate(table, query, field, options), - 'indexerStorage.search': ({ table, query, options }) => - this.indexerStorage.search(table, query, options), - 'indexerStorage.subscribeSearch': ({ table, query, options }) => - this.indexerStorage.search$(table, query, options), - 'indexerStorage.subscribeAggregate': ({ table, query, field, options }) => - this.indexerStorage.aggregate$(table, query, field, options), - 'indexerStorage.waitForConnected': (_, ctx) => - this.indexerStorage.connection.waitForConnected(ctx.signal), 'indexerSync.state': () => this.indexerSync.state$, 'indexerSync.docState': (docId: string) => this.indexerSync.docState$(docId), @@ -287,6 +277,14 @@ class StoreConsumer { this.indexerSync.waitForCompleted(ctx.signal), 'indexerSync.waitForDocCompleted': (docId: string, ctx) => this.indexerSync.waitForDocCompleted(docId, ctx.signal), + 'indexerSync.aggregate': ({ table, query, field, options }) => + this.indexerSync.aggregate(table, query, field, options), + 'indexerSync.search': ({ table, query, options }) => + this.indexerSync.search(table, query, options), + 'indexerSync.subscribeSearch': ({ table, query, options }) => + this.indexerSync.search$(table, query, options), + 'indexerSync.subscribeAggregate': ({ table, query, field, options }) => + this.indexerSync.aggregate$(table, query, field, options), }); } } diff --git a/packages/common/nbstore/src/worker/ops.ts b/packages/common/nbstore/src/worker/ops.ts index e71b37dae6..09bdc906a7 100644 --- a/packages/common/nbstore/src/worker/ops.ts +++ b/packages/common/nbstore/src/worker/ops.ts @@ -1,6 +1,5 @@ import type { AvailableStorageImplementations } from '../impls'; import type { - AggregateOptions, AggregateResult, BlobRecord, DocClock, @@ -10,7 +9,6 @@ import type { DocUpdate, ListedBlobRecord, Query, - SearchOptions, SearchResult, StorageType, } from '../storage'; @@ -69,36 +67,6 @@ interface GroupedWorkerOps { waitForConnected: [void, void]; }; - indexerStorage: { - search: [ - { table: string; query: Query; options?: SearchOptions }, - SearchResult, - ]; - aggregate: [ - { - table: string; - query: Query; - field: string; - options?: AggregateOptions; - }, - AggregateResult, - ]; - subscribeSearch: [ - { table: string; query: Query; options?: SearchOptions }, - SearchResult, - ]; - subscribeAggregate: [ - { - table: string; - query: Query; - field: string; - options?: AggregateOptions; - }, - AggregateResult, - ]; - waitForConnected: [void, void]; - }; - docSync: { state: [void, DocSyncState]; docState: [string, DocSyncDocState]; @@ -137,6 +105,40 @@ interface GroupedWorkerOps { addPriority: [{ docId: string; priority: number }, boolean]; waitForCompleted: [void, void]; waitForDocCompleted: [string, void]; + search: [ + { + table: string; + query: Query; + options?: any; + }, + SearchResult, + ]; + aggregate: [ + { + table: string; + query: Query; + field: string; + options?: any; + }, + AggregateResult, + ]; + subscribeSearch: [ + { + table: string; + query: Query; + options?: any; + }, + SearchResult, + ]; + subscribeAggregate: [ + { + table: string; + query: Query; + field: string; + options?: any; + }, + AggregateResult, + ]; }; } diff --git a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts index 51f5992149..ef99800a6c 100644 --- a/packages/frontend/core/src/modules/docs-search/services/docs-search.ts +++ b/packages/frontend/core/src/modules/docs-search/services/docs-search.ts @@ -112,6 +112,7 @@ export class DocsSearchService extends Service { }, ], }, + prefer: 'remote', } ) .pipe( diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 50e2809e2a..388fd1c231 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -3,7 +3,6 @@ import type { FlagInfo } from './types'; // const isNotStableBuild = BUILD_CONFIG.appBuildType !== 'stable'; const isDesktopEnvironment = BUILD_CONFIG.isElectron; const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary'; -const isBetaBuild = BUILD_CONFIG.appBuildType === 'beta'; const isMobile = BUILD_CONFIG.isMobileEdition; export const AFFINE_FLAGS = { @@ -266,13 +265,6 @@ export const AFFINE_FLAGS = { configurable: isCanaryBuild, defaultState: false, }, - enable_cloud_indexer: { - category: 'affine', - displayName: 'Enable Cloud Indexer', - description: 'Use cloud indexer to search docs', - configurable: isBetaBuild || isCanaryBuild, - defaultState: false, - }, enable_adapter_panel: { category: 'affine', displayName: diff --git a/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts b/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts index 5b8d73bb58..ea6b23fb7a 100644 --- a/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts +++ b/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts @@ -25,6 +25,11 @@ export class QuickSearch extends Entity { .flat() .map(items => items.flat()); + readonly error$ = this.state$ + .map(s => s?.sessions.map(session => session.error$) ?? []) + .flat() + .map(items => items.find(v => !!v) ?? null); + readonly show$ = this.state$.map(s => !!s); readonly options$ = this.state$.map(s => s?.options); diff --git a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts index bdd736b6f8..d240f8fd40 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts @@ -6,11 +6,12 @@ import { onStart, } from '@toeverything/infra'; import { truncate } from 'lodash-es'; -import { map, of, switchMap, tap, throttleTime } from 'rxjs'; +import { catchError, EMPTY, map, of, switchMap, tap, throttleTime } from 'rxjs'; import type { DocRecord, DocsService } from '../../doc'; import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocsSearchService } from '../../docs-search'; +import type { WorkspaceService } from '../../workspace'; import type { QuickSearchSession } from '../providers/quick-search-provider'; import type { QuickSearchItem } from '../types/item'; @@ -26,6 +27,7 @@ export class DocsQuickSearchSession implements QuickSearchSession<'docs', DocsPayload> { constructor( + private readonly workspaceService: WorkspaceService, private readonly docsSearchService: DocsSearchService, private readonly docsService: DocsService, private readonly docDisplayMetaService: DocDisplayMetaService @@ -41,10 +43,17 @@ export class DocsQuickSearchSession private readonly isQueryLoading$ = new LiveData(false); + isCloudWorkspace = this.workspaceService.workspace.flavour !== 'local'; + isLoading$ = LiveData.computed(get => { - return get(this.isIndexerLoading$) || get(this.isQueryLoading$); + return ( + (this.isCloudWorkspace ? false : get(this.isIndexerLoading$)) || + get(this.isQueryLoading$) + ); }); + error$ = new LiveData(null); + query$ = new LiveData(''); items$ = new LiveData[]>([]); @@ -102,9 +111,16 @@ export class DocsQuickSearchSession this.isQueryLoading$.next(false); }), onStart(() => { + this.error$.next(null); this.items$.next([]); this.isQueryLoading$.next(true); }), + catchError(err => { + this.error$.next(err instanceof Error ? err.message : err); + this.items$.next([]); + this.isQueryLoading$.next(false); + return EMPTY; + }), onComplete(() => {}) ); }) diff --git a/packages/frontend/core/src/modules/quicksearch/index.ts b/packages/frontend/core/src/modules/quicksearch/index.ts index b469c89b9f..be3f4f48a7 100644 --- a/packages/frontend/core/src/modules/quicksearch/index.ts +++ b/packages/frontend/core/src/modules/quicksearch/index.ts @@ -55,6 +55,7 @@ export function configureQuickSearchModule(framework: Framework) { .entity(QuickSearch) .entity(CommandsQuickSearchSession, [GlobalContextService]) .entity(DocsQuickSearchSession, [ + WorkspaceService, DocsSearchService, DocsService, DocDisplayMetaService, diff --git a/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts b/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts index d17a477b69..8d83df1121 100644 --- a/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts +++ b/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts @@ -8,7 +8,7 @@ export type QuickSearchFunction = ( export interface QuickSearchSession { items$: LiveData[]>; - isError$?: LiveData; + error$?: LiveData; isLoading$?: LiveData; loadingProgress$?: LiveData; hasMore$?: LiveData; diff --git a/packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts b/packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts index 84327d9eeb..db758e1b9f 100644 --- a/packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts +++ b/packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { globalStyle, style } from '@vanilla-extract/css'; export const root = style({}); @@ -195,3 +196,9 @@ export const itemSubtitle = style({ fontWeight: 400, textAlign: 'justify', }); + +export const errorMessage = style({ + padding: '0px 8px 8px', + fontSize: cssVar('fontXs'), + color: cssVarV2('status/error'), +}); diff --git a/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx b/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx index 20421f558b..57a5e037ce 100644 --- a/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx +++ b/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx @@ -24,6 +24,7 @@ export const CMDK = ({ className, query, groups: newGroups = [], + error, inputLabel, placeholder, loading: newLoading = false, @@ -33,6 +34,7 @@ export const CMDK = ({ }: React.PropsWithChildren<{ className?: string; query: string; + error?: ReactNode; inputLabel?: ReactNode; placeholder?: string; loading?: boolean; @@ -200,6 +202,7 @@ export const CMDK = ({ + {error &&

{error}

} {groups.map(({ group, items }) => { return ( { const loading = useLiveData(quickSearch.isLoading$); const loadingProgress = useLiveData(quickSearch.loadingProgress$); const items = useLiveData(quickSearch.items$); + const error = useLiveData(quickSearch.error$); const options = useLiveData(quickSearch.options$); const i18n = useI18n(); @@ -79,6 +81,7 @@ export const QuickSearchContainer = () => { !!m); + loading.value = false; return docs; + }), + catchError(err => { + loading.value = false; + const userFriendlyError = UserFriendlyError.fromAny(err); + return of([ + { + name: html`${I18n.t( + `error.${userFriendlyError.name}`, + userFriendlyError.data + )}`, + key: 'error', + icon: WarningIcon(), + disabled: true, + action: () => {}, + }, + ]); }) ), [] @@ -129,6 +150,7 @@ export class SearchMenuService extends Service { name: I18n.t('com.affine.editor.at-menu.link-to-doc', { query, }), + loading: loading, items: docsSignal, maxDisplay: MAX_DOCS, overflowText, @@ -170,7 +192,7 @@ export class SearchMenuService extends Service { return { id, title, - highlights: node.highlights.title[0], + highlights: node.highlights?.title?.[0], }; }) ) diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts index 68e076711d..4e55683d5b 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -1,4 +1,3 @@ -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { DebugLogger } from '@affine/debug'; import { createWorkspaceMutation, @@ -7,6 +6,7 @@ import { getWorkspacesQuery, Permission, ServerDeploymentType, + ServerFeature, } from '@affine/graphql'; import type { BlobStorage, @@ -86,7 +86,6 @@ const logger = new DebugLogger('affine:cloud-workspace-flavour-provider'); class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { private readonly authService: AuthService; private readonly graphqlService: GraphQLService; - private readonly featureFlagService: FeatureFlagService; private readonly unsubscribeAccountChanged: () => void; constructor( @@ -95,7 +94,6 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ) { this.authService = server.scope.get(AuthService); this.graphqlService = server.scope.get(GraphQLService); - this.featureFlagService = server.scope.get(FeatureFlagService); this.unsubscribeAccountChanged = this.server.scope.eventBus.on( AccountChanged, () => { @@ -477,24 +475,14 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { id: `${this.flavour}:${workspaceId}`, }, }, - indexer: this.featureFlagService.flags.enable_cloud_indexer.value - ? { - name: 'CloudIndexerStorage', - opts: { - flavour: this.flavour, - type: 'workspace', - id: workspaceId, - serverBaseUrl: this.server.serverMetadata.baseUrl, - }, - } - : { - name: 'IndexedDBIndexerStorage', - opts: { - flavour: this.flavour, - type: 'workspace', - id: workspaceId, - }, - }, + indexer: { + name: 'IndexedDBIndexerStorage', + opts: { + flavour: this.flavour, + type: 'workspace', + id: workspaceId, + }, + }, indexerSync: { name: 'IndexedDBIndexerSyncStorage', opts: { @@ -535,6 +523,19 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ServerDeploymentType.Selfhosted, }, }, + indexer: this.server.config$.value.features.includes( + ServerFeature.Indexer + ) + ? { + name: 'CloudIndexerStorage', + opts: { + flavour: this.flavour, + type: 'workspace', + id: workspaceId, + serverBaseUrl: this.server.serverMetadata.baseUrl, + }, + } + : undefined, }, v1: { doc: this.DocStorageV1Type diff --git a/tests/kit/src/utils/cloud.ts b/tests/kit/src/utils/cloud.ts index c1a388fc5f..230d443ed6 100644 --- a/tests/kit/src/utils/cloud.ts +++ b/tests/kit/src/utils/cloud.ts @@ -308,5 +308,7 @@ export async function enableCloudWorkspaceFromShareButton(page: Page) { export async function enableShare(page: Page) { await page.getByTestId('cloud-share-menu-button').click(); await page.getByTestId('share-link-menu-trigger').click(); + // wait for the menu to be visible + await page.waitForTimeout(500); await page.getByTestId('share-link-menu-enable-share').click(); }