feat(core): support collection search for ai chat (#10987)

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

![截屏2025-03-19 14.15.54.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/573450f9-791b-4cd9-9c75-df93bf9966b4.png)
This commit is contained in:
akumatus
2025-03-20 00:34:12 +00:00
parent f889886b31
commit ee337a16af
7 changed files with 139 additions and 25 deletions

View File

@@ -1,4 +1,5 @@
import type {
SearchCollectionMenuAction,
SearchDocMenuAction,
SearchTagMenuAction,
} from '@affine/core/modules/search-menu/services';
@@ -43,4 +44,9 @@ export interface SearchMenuConfig {
action: SearchTagMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
getCollectionMenuGroup: (
query: string,
action: SearchCollectionMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
}

View File

@@ -1,5 +1,8 @@
import { toast } from '@affine/component';
import type { TagMeta } from '@affine/core/components/page-list';
import type {
CollectionMeta,
TagMeta,
} from '@affine/core/components/page-list';
import { ShadowlessElement } from '@blocksuite/affine/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
@@ -314,6 +317,13 @@ export class ChatPanelAddPopover extends SignalWatcher(
this.abortController.signal
);
break;
case AddPopoverMode.Collections:
this._searchGroup = this.searchMenuConfig.getCollectionMenuGroup(
this._query,
this._addCollectionChip,
this.abortController.signal
);
break;
default:
this._searchGroup = this.searchMenuConfig.getDocMenuGroup(
this._query,
@@ -335,6 +345,10 @@ export class ChatPanelAddPopover extends SignalWatcher(
this.abortController.abort();
};
private readonly _addCollectionChip = (_collection: CollectionMeta) => {
this.abortController.abort();
};
private readonly _handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing) return;

View File

@@ -94,6 +94,13 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
getTagMenuGroup: (query, action, abortSignal) => {
return searchMenuService.getTagMenuGroup(query, action, abortSignal);
},
getCollectionMenuGroup: (query, action, abortSignal) => {
return searchMenuService.getCollectionMenuGroup(
query,
action,
abortSignal
);
},
};
const previewSpecBuilder = enableFootnoteConfigExtension(
SpecProvider._.getSpec('preview:page')

View File

@@ -1,5 +1,6 @@
import { type Framework } from '@toeverything/infra';
import { CollectionService } from '../collection';
import { DocDisplayMetaService } from '../doc-display-meta';
import { DocsSearchService } from '../docs-search';
import { RecentDocsService } from '../quicksearch';
@@ -16,5 +17,6 @@ export function configSearchMenuModule(framework: Framework) {
RecentDocsService,
DocsSearchService,
TagService,
CollectionService,
]);
}

View File

@@ -1,4 +1,7 @@
import type { TagMeta } from '@affine/core/components/page-list';
import type {
CollectionMeta,
TagMeta,
} from '@affine/core/components/page-list';
import { fuzzyMatch } from '@affine/core/utils/fuzzy-match';
import { I18n } from '@affine/i18n';
import type {
@@ -7,14 +10,16 @@ import type {
} from '@blocksuite/affine/blocks/root';
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
import type { DocMeta } from '@blocksuite/affine/store';
import { CollectionsIcon } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { Service } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import Fuse from 'fuse.js';
import Fuse, { type FuseResultMatch } from 'fuse.js';
import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { map, takeWhile } from 'rxjs';
import type { CollectionService } from '../../collection';
import type { DocDisplayMetaService } from '../../doc-display-meta';
import type { DocsSearchService } from '../../docs-search';
import { type RecentDocsService } from '../../quicksearch';
@@ -32,13 +37,18 @@ export type SearchDocMenuAction = (meta: DocMeta) => Promise<void> | void;
export type SearchTagMenuAction = (tagId: TagMeta) => Promise<void> | void;
export type SearchCollectionMenuAction = (
collection: CollectionMeta
) => Promise<void> | void;
export class SearchMenuService extends Service {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly docDisplayMetaService: DocDisplayMetaService,
private readonly recentDocsService: RecentDocsService,
private readonly docsSearch: DocsSearchService,
private readonly tagService: TagService
private readonly tagService: TagService,
private readonly collectionService: CollectionService
) {
super();
}
@@ -218,13 +228,38 @@ export class SearchMenuService extends Service {
};
}
private highlightFuseTitle(
matches: readonly FuseResultMatch[] | undefined,
title: string,
key: string
): string {
if (!matches) {
return title;
}
const normalizedRange = ([start, end]: [number, number]) =>
[
start,
end + 1 /* in fuse, the `end` is different from the `substring` */,
] as [number, number];
const titleMatches = matches
?.filter(match => match.key === key)
.flatMap(match => match.indices.map(normalizedRange));
return (
highlighter(
title,
`<span style="color: ${cssVarV2('text/emphasis')}">`,
'</span>',
titleMatches ?? []
) ?? title
);
}
getTagMenuGroup(
query: string,
action: SearchTagMenuAction,
_abortSignal: AbortSignal
): LinkedMenuGroup {
const tags: TagMeta[] = this.tagService.tagList.tagMetas$.value;
if (query.trim().length === 0) {
return {
name: I18n.t('com.affine.editor.at-menu.tags', {
@@ -241,7 +276,6 @@ export class SearchMenuService extends Service {
ignoreLocation: true,
threshold: 0.0,
});
const result = fuse.search(query);
return {
@@ -249,27 +283,12 @@ export class SearchMenuService extends Service {
query,
}),
items: result.map(item => {
const normalizedRange = ([start, end]: [number, number]) =>
[
start,
end + 1 /* in fuse, the `end` is different from the `substring` */,
] as [number, number];
const titleMatches = item.matches
?.filter(match => match.key === 'title')
.flatMap(match => match.indices.map(normalizedRange));
const hTitle = highlighter(
const title = this.highlightFuseTitle(
item.matches,
item.item.title,
`<span style="color: ${cssVarV2('text/emphasis')}">`,
'</span>',
titleMatches ?? []
);
return this.toTagMenuItem(
{
...item.item,
title: hTitle ?? item.item.title,
},
action
'title'
);
return this.toTagMenuItem({ ...item.item, title }, action);
}),
};
}
@@ -294,4 +313,65 @@ export class SearchMenuService extends Service {
},
};
}
getCollectionMenuGroup(
query: string,
action: SearchCollectionMenuAction,
_abortSignal: AbortSignal
): LinkedMenuGroup {
const collections = this.collectionService.collections$.value;
if (query.trim().length === 0) {
return {
name: I18n.t('com.affine.editor.at-menu.collections', {
query,
}),
items: collections.map(collection =>
this.toCollectionMenuItem(
{
...collection,
title: collection.name,
},
action
)
),
};
}
const fuse = new Fuse(collections, {
keys: ['name'],
includeMatches: true,
includeScore: true,
ignoreLocation: true,
threshold: 0.0,
});
const result = fuse.search(query);
return {
name: I18n.t('com.affine.editor.at-menu.link-to-doc', {
query,
}),
items: result.map(item => {
const title = this.highlightFuseTitle(
item.matches,
item.item.name,
'name'
);
return this.toCollectionMenuItem({ ...item.item, title }, action);
}),
};
}
private toCollectionMenuItem(
collection: CollectionMeta,
action: SearchCollectionMenuAction
): LinkedMenuItem {
return {
key: collection.id,
name: html`${unsafeHTML(collection.title)}`,
icon: CollectionsIcon(),
action: async () => {
await action(collection);
},
};
}
}