mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
feat(core): support ai doc search panel (#9831)
Support issue [BS-2351](https://linear.app/affine-design/issue/BS-2351) and [BS-2461](https://linear.app/affine-design/issue/BS-2461). ## What changed? - Add `chat-panel-add-popover` component. - Refactor part of `AtMenuConfigService` into `DocSearchMenuService`. - Add signal `content` property to `DocChip` interface for markdown content update. <div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov">录屏2025-01-21 18.46.29.mov</video>
This commit is contained in:
@@ -3,24 +3,21 @@ import { type Framework } from '@toeverything/infra';
|
||||
import { WorkspaceDialogService } from '../dialogs';
|
||||
import { DocsService } from '../doc';
|
||||
import { DocDisplayMetaService } from '../doc-display-meta';
|
||||
import { DocsSearchService } from '../docs-search';
|
||||
import { DocSearchMenuService } from '../doc-search-menu/services';
|
||||
import { EditorSettingService } from '../editor-setting';
|
||||
import { JournalService } from '../journal';
|
||||
import { RecentDocsService } from '../quicksearch';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { WorkspaceScope } from '../workspace';
|
||||
import { AtMenuConfigService } from './services';
|
||||
|
||||
export function configAtMenuConfigModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(AtMenuConfigService, [
|
||||
WorkspaceService,
|
||||
JournalService,
|
||||
DocDisplayMetaService,
|
||||
WorkspaceDialogService,
|
||||
RecentDocsService,
|
||||
EditorSettingService,
|
||||
DocsService,
|
||||
DocsSearchService,
|
||||
DocSearchMenuService,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { fuzzyMatch } from '@affine/core/utils/fuzzy-match';
|
||||
import { I18n, i18nTime } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import type { EditorHost } from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
type AffineInlineEditor,
|
||||
createSignalFromObservable,
|
||||
type DocMode,
|
||||
type LinkedMenuGroup,
|
||||
type LinkedMenuItem,
|
||||
@@ -22,29 +20,22 @@ import { computed } from '@preact/signals-core';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import type { WorkspaceDialogService } from '../../dialogs';
|
||||
import type { DocsService } from '../../doc';
|
||||
import type { DocDisplayMetaService } from '../../doc-display-meta';
|
||||
import type { DocsSearchService } from '../../docs-search';
|
||||
import type { DocSearchMenuService } from '../../doc-search-menu/services';
|
||||
import type { EditorSettingService } from '../../editor-setting';
|
||||
import { type JournalService, suggestJournalDate } from '../../journal';
|
||||
import type { RecentDocsService } from '../../quicksearch';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
const MAX_DOCS = 3;
|
||||
export class AtMenuConfigService extends Service {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly journalService: JournalService,
|
||||
private readonly docDisplayMetaService: DocDisplayMetaService,
|
||||
private readonly dialogService: WorkspaceDialogService,
|
||||
private readonly recentDocsService: RecentDocsService,
|
||||
private readonly editorSettingService: EditorSettingService,
|
||||
private readonly docsService: DocsService,
|
||||
private readonly docsSearch: DocsSearchService
|
||||
private readonly docsSearchMenuService: DocSearchMenuService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -65,149 +56,6 @@ export class AtMenuConfigService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
private linkToDocGroup(
|
||||
query: string,
|
||||
close: () => void,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
abortSignal: AbortSignal
|
||||
): LinkedMenuGroup {
|
||||
const currentWorkspace = this.workspaceService.workspace;
|
||||
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
|
||||
const isJournal = (d: DocMeta) =>
|
||||
!!this.journalService.journalDate$(d.id).value;
|
||||
|
||||
const docDisplayMetaService = this.docDisplayMetaService;
|
||||
|
||||
type DocMetaWithHighlights = DocMeta & {
|
||||
highlights: string | undefined;
|
||||
};
|
||||
|
||||
const toDocItem = (meta: DocMetaWithHighlights): LinkedMenuItem | null => {
|
||||
if (isJournal(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (meta.trash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = docDisplayMetaService.title$(meta.id, {
|
||||
reference: true,
|
||||
}).value;
|
||||
|
||||
if (!fuzzyMatch(title, query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: meta.highlights ? html`${unsafeHTML(meta.highlights)}` : title,
|
||||
key: meta.id,
|
||||
icon: docDisplayMetaService
|
||||
.icon$(meta.id, {
|
||||
type: 'lit',
|
||||
reference: true,
|
||||
})
|
||||
.value(),
|
||||
action: () => {
|
||||
close();
|
||||
track.doc.editor.atMenu.linkDoc();
|
||||
this.insertDoc(inlineEditor, meta.id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const showRecent = query.trim().length === 0;
|
||||
|
||||
if (showRecent) {
|
||||
const recentDocs = this.recentDocsService.getRecentDocs();
|
||||
return {
|
||||
name: I18n.t('com.affine.editor.at-menu.recent-docs'),
|
||||
items: recentDocs
|
||||
.map(doc => {
|
||||
const meta = rawMetas.find(meta => meta.id === doc.id);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
const item = toDocItem({
|
||||
...meta,
|
||||
highlights: undefined,
|
||||
});
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter(item => !!item),
|
||||
};
|
||||
} else {
|
||||
const { signal: docsSignal, cleanup } = createSignalFromObservable(
|
||||
this.searchDocs$(query).pipe(
|
||||
map(result => {
|
||||
const docs = result
|
||||
.map(doc => {
|
||||
const meta = rawMetas.find(meta => meta.id === doc.id);
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const highlights =
|
||||
'highlights' in doc ? doc.highlights : undefined;
|
||||
|
||||
const docItem = toDocItem({
|
||||
...meta,
|
||||
highlights,
|
||||
});
|
||||
|
||||
if (!docItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return docItem;
|
||||
})
|
||||
.filter(m => !!m);
|
||||
|
||||
return docs;
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const { signal: isIndexerLoading, cleanup: cleanupIndexerLoading } =
|
||||
createSignalFromObservable(
|
||||
this.docsSearch.indexer.status$.pipe(
|
||||
map(
|
||||
status => status.remaining !== undefined && status.remaining > 0
|
||||
)
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
const overflowText = computed(() => {
|
||||
const overflowCount = docsSignal.value.length - MAX_DOCS;
|
||||
return I18n.t('com.affine.editor.at-menu.more-docs-hint', {
|
||||
count: overflowCount > 100 ? '100+' : overflowCount,
|
||||
});
|
||||
});
|
||||
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
cleanup();
|
||||
cleanupIndexerLoading();
|
||||
});
|
||||
|
||||
return {
|
||||
name: I18n.t('com.affine.editor.at-menu.link-to-doc', {
|
||||
query,
|
||||
}),
|
||||
loading: isIndexerLoading,
|
||||
loadingText: I18n.t('com.affine.editor.at-menu.loading'),
|
||||
items: docsSignal,
|
||||
maxDisplay: MAX_DOCS,
|
||||
overflowText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private newDocMenuGroup(
|
||||
query: string,
|
||||
close: () => void,
|
||||
@@ -399,6 +247,35 @@ export class AtMenuConfigService extends Service {
|
||||
};
|
||||
}
|
||||
|
||||
private linkToDocGroup(
|
||||
query: string,
|
||||
close: () => void,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
abortSignal: AbortSignal
|
||||
): LinkedMenuGroup {
|
||||
const action = (meta: DocMeta) => {
|
||||
close();
|
||||
track.doc.editor.atMenu.linkDoc();
|
||||
this.insertDoc(inlineEditor, meta.id);
|
||||
};
|
||||
const result = this.docsSearchMenuService.getDocMenuGroup(
|
||||
query,
|
||||
action,
|
||||
abortSignal
|
||||
);
|
||||
const filterItem = (item: LinkedMenuItem) => {
|
||||
const isJournal = !!this.journalService.journalDate$(item.key).value;
|
||||
return !isJournal;
|
||||
};
|
||||
const items = result.items;
|
||||
if (Array.isArray(items)) {
|
||||
result.items = items.filter(filterItem);
|
||||
} else {
|
||||
result.items = computed(() => items.value.filter(filterItem));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private getMenusFn(): LinkedWidgetConfig['getMenus'] {
|
||||
return (query, close, editorHost, inlineEditor, abortSignal) => {
|
||||
return [
|
||||
@@ -422,49 +299,4 @@ export class AtMenuConfigService extends Service {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// only search docs by title, excluding blocks
|
||||
private searchDocs$(query: string) {
|
||||
return this.docsSearch.indexer.docIndex
|
||||
.aggregate$(
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'match',
|
||||
field: 'title',
|
||||
match: query,
|
||||
},
|
||||
],
|
||||
},
|
||||
'docId',
|
||||
{
|
||||
hits: {
|
||||
fields: ['docId', 'title'],
|
||||
pagination: {
|
||||
limit: 1,
|
||||
},
|
||||
highlights: [
|
||||
{
|
||||
field: 'title',
|
||||
before: `<span style="color: ${cssVarV2('text/emphasis')}">`,
|
||||
end: '</span>',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map(({ buckets }) =>
|
||||
buckets.map(bucket => {
|
||||
return {
|
||||
id: bucket.key,
|
||||
title: bucket.hits.nodes[0].fields.title,
|
||||
highlights: bucket.hits.nodes[0].highlights.title[0],
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { DocDisplayMetaService } from '../doc-display-meta';
|
||||
import { DocsSearchService } from '../docs-search';
|
||||
import { RecentDocsService } from '../quicksearch';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { DocSearchMenuService } from './services';
|
||||
|
||||
export function configDocSearchMenuModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(DocSearchMenuService, [
|
||||
WorkspaceService,
|
||||
DocDisplayMetaService,
|
||||
RecentDocsService,
|
||||
DocsSearchService,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { fuzzyMatch } from '@affine/core/utils/fuzzy-match';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import type {
|
||||
LinkedMenuGroup,
|
||||
LinkedMenuItem,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import { createSignalFromObservable } from '@blocksuite/affine/blocks';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import type { DocDisplayMetaService } from '../../doc-display-meta';
|
||||
import type { DocsSearchService } from '../../docs-search';
|
||||
import type { RecentDocsService } from '../../quicksearch';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
const MAX_DOCS = 3;
|
||||
|
||||
type DocMetaWithHighlights = DocMeta & {
|
||||
highlights?: string;
|
||||
};
|
||||
|
||||
export type SearchDocMenuAction = (meta: DocMeta) => Promise<void> | void;
|
||||
|
||||
export class DocSearchMenuService extends Service {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly docDisplayMetaService: DocDisplayMetaService,
|
||||
private readonly recentDocsService: RecentDocsService,
|
||||
private readonly docsSearch: DocsSearchService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getDocMenuGroup(
|
||||
query: string,
|
||||
action: SearchDocMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
): LinkedMenuGroup {
|
||||
const showRecent = query.trim().length === 0;
|
||||
if (showRecent) {
|
||||
return this.getRecentDocMenuGroup(action);
|
||||
} else {
|
||||
return this.getSearchDocMenuGroup(query, action, abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
private getRecentDocMenuGroup(action: SearchDocMenuAction): LinkedMenuGroup {
|
||||
const currentWorkspace = this.workspaceService.workspace;
|
||||
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
|
||||
const recentDocs = this.recentDocsService.getRecentDocs();
|
||||
return {
|
||||
name: I18n.t('com.affine.editor.at-menu.recent-docs'),
|
||||
items: recentDocs
|
||||
.map(doc => {
|
||||
const meta = rawMetas.find(meta => meta.id === doc.id);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
return this.toDocMenuItem(meta, action);
|
||||
})
|
||||
.filter(m => !!m),
|
||||
};
|
||||
}
|
||||
|
||||
private getSearchDocMenuGroup(
|
||||
query: string,
|
||||
action: SearchDocMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
): LinkedMenuGroup {
|
||||
const currentWorkspace = this.workspaceService.workspace;
|
||||
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
|
||||
const { signal: docsSignal, cleanup: cleanupDocs } =
|
||||
createSignalFromObservable(
|
||||
this.searchDocs$(query).pipe(
|
||||
map(result => {
|
||||
const docs = result
|
||||
.map(doc => {
|
||||
const meta = rawMetas.find(meta => meta.id === doc.id);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
const highlights =
|
||||
'highlights' in doc ? doc.highlights : undefined;
|
||||
return this.toDocMenuItem(
|
||||
{
|
||||
...meta,
|
||||
highlights,
|
||||
},
|
||||
action,
|
||||
query
|
||||
);
|
||||
})
|
||||
.filter(m => !!m);
|
||||
return docs;
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const { signal: isIndexerLoading, cleanup: cleanupIndexerLoading } =
|
||||
createSignalFromObservable(
|
||||
this.docsSearch.indexer.status$.pipe(
|
||||
map(status => status.remaining !== undefined && status.remaining > 0)
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
const overflowText = computed(() => {
|
||||
const overflowCount = docsSignal.value.length - MAX_DOCS;
|
||||
return I18n.t('com.affine.editor.at-menu.more-docs-hint', {
|
||||
count: overflowCount > 100 ? '100+' : overflowCount,
|
||||
});
|
||||
});
|
||||
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
cleanupDocs();
|
||||
cleanupIndexerLoading();
|
||||
});
|
||||
|
||||
return {
|
||||
name: I18n.t('com.affine.editor.at-menu.link-to-doc', {
|
||||
query,
|
||||
}),
|
||||
loading: isIndexerLoading,
|
||||
loadingText: I18n.t('com.affine.editor.at-menu.loading'),
|
||||
items: docsSignal,
|
||||
maxDisplay: MAX_DOCS,
|
||||
overflowText,
|
||||
};
|
||||
}
|
||||
|
||||
// only search docs by title, excluding blocks
|
||||
private searchDocs$(query: string) {
|
||||
return this.docsSearch.indexer.docIndex
|
||||
.aggregate$(
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'match',
|
||||
field: 'title',
|
||||
match: query,
|
||||
},
|
||||
],
|
||||
},
|
||||
'docId',
|
||||
{
|
||||
hits: {
|
||||
fields: ['docId', 'title'],
|
||||
pagination: {
|
||||
limit: 1,
|
||||
},
|
||||
highlights: [
|
||||
{
|
||||
field: 'title',
|
||||
before: `<span style="color: ${cssVarV2('text/emphasis')}">`,
|
||||
end: '</span>',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map(({ buckets }) =>
|
||||
buckets.map(bucket => {
|
||||
return {
|
||||
id: bucket.key,
|
||||
title: bucket.hits.nodes[0].fields.title,
|
||||
highlights: bucket.hits.nodes[0].highlights.title[0],
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private toDocMenuItem(
|
||||
meta: DocMetaWithHighlights,
|
||||
action: SearchDocMenuAction,
|
||||
query?: string
|
||||
): LinkedMenuItem | null {
|
||||
const title = this.docDisplayMetaService.title$(meta.id, {
|
||||
reference: true,
|
||||
}).value;
|
||||
|
||||
if (meta.trash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query && !fuzzyMatch(title, query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: meta.highlights ? html`${unsafeHTML(meta.highlights)}` : title,
|
||||
key: meta.id,
|
||||
icon: this.docDisplayMetaService
|
||||
.icon$(meta.id, {
|
||||
type: 'lit',
|
||||
reference: true,
|
||||
})
|
||||
.value(),
|
||||
action: async () => {
|
||||
await action(meta);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { configureDocModule } from './doc';
|
||||
import { configureDocDisplayMetaModule } from './doc-display-meta';
|
||||
import { configureDocInfoModule } from './doc-info';
|
||||
import { configureDocLinksModule } from './doc-link';
|
||||
import { configDocSearchMenuModule } from './doc-search-menu';
|
||||
import { configureDocsSearchModule } from './docs-search';
|
||||
import { configureEditorModule } from './editor';
|
||||
import { configureEditorSettingModule } from './editor-setting';
|
||||
@@ -91,6 +92,7 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureDocInfoModule(framework);
|
||||
configureOpenInApp(framework);
|
||||
configAtMenuConfigModule(framework);
|
||||
configDocSearchMenuModule(framework);
|
||||
configureDndModule(framework);
|
||||
configureCommonGlobalStorageImpls(framework);
|
||||
configureAINetworkSearchModule(framework);
|
||||
|
||||
Reference in New Issue
Block a user