From a3164b4ccf6473b2a3166e14d1b3190da3d250a1 Mon Sep 17 00:00:00 2001 From: akumatus Date: Tue, 21 Jan 2025 11:20:18 +0000 Subject: [PATCH] feat(core): support ai doc search panel (#9831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.
🎥 Video uploaded on Graphite:
--- .../blocks/src/root-block/widgets/index.ts | 1 + .../root-block/widgets/linked-doc/config.ts | 4 +- .../presets/ai/chat-panel/chat-config.ts | 10 + .../presets/ai/chat-panel/chat-context.ts | 4 +- .../presets/ai/chat-panel/chat-panel-chips.ts | 136 ++++++++++- .../presets/ai/chat-panel/chat-panel-input.ts | 12 +- .../ai/chat-panel/components/add-popover.ts | 157 ++++++++++++ .../presets/ai/chat-panel/components/chip.ts | 8 +- .../ai/chat-panel/components/doc-chip.ts | 144 ++++++----- .../presets/ai/chat-panel/components/utils.ts | 4 +- .../blocksuite/presets/ai/chat-panel/index.ts | 12 +- .../blocksuite/presets/ai/utils/extract.ts | 4 +- .../core/src/blocksuite/presets/effects.ts | 2 + .../pages/workspace/detail-page/tabs/chat.tsx | 11 + .../core/src/modules/at-menu-config/index.ts | 9 +- .../modules/at-menu-config/services/index.ts | 230 +++--------------- .../core/src/modules/doc-search-menu/index.ts | 18 ++ .../modules/doc-search-menu/services/index.ts | 213 ++++++++++++++++ packages/frontend/core/src/modules/index.ts | 2 + 19 files changed, 689 insertions(+), 292 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/add-popover.ts create mode 100644 packages/frontend/core/src/modules/doc-search-menu/index.ts create mode 100644 packages/frontend/core/src/modules/doc-search-menu/services/index.ts diff --git a/blocksuite/blocks/src/root-block/widgets/index.ts b/blocksuite/blocks/src/root-block/widgets/index.ts index b4bd93229f1ad..74fda93146d5b 100644 --- a/blocksuite/blocks/src/root-block/widgets/index.ts +++ b/blocksuite/blocks/src/root-block/widgets/index.ts @@ -30,6 +30,7 @@ export { AffineImageToolbarWidget } from './image-toolbar/index.js'; export { AffineInnerModalWidget } from './inner-modal/inner-modal.js'; export * from './keyboard-toolbar/index.js'; export { + type LinkedMenuAction, type LinkedMenuGroup, type LinkedMenuItem, type LinkedWidgetConfig, diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts index ce98328ca3eb2..1c957a5ca8096 100644 --- a/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts @@ -69,9 +69,11 @@ export type LinkedMenuItem = { icon: TemplateResult<1>; suffix?: string | TemplateResult<1>; // disabled?: boolean; - action: () => Promise | void; + action: LinkedMenuAction; }; +export type LinkedMenuAction = () => Promise | void; + export type LinkedMenuGroup = { name: string; items: LinkedMenuItem[] | Signal; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-config.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-config.ts index 4a2b916cf9e30..60bea05e49709 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-config.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-config.ts @@ -1,3 +1,5 @@ +import type { SearchDocMenuAction } from '@affine/core/modules/doc-search-menu/services'; +import type { LinkedMenuGroup } from '@blocksuite/affine/blocks'; import type { Store } from '@blocksuite/affine/store'; import type { Signal } from '@preact/signals-core'; @@ -15,3 +17,11 @@ export interface DocDisplayConfig { }; getDoc: (docId: string) => Store | null; } + +export interface DocSearchMenuConfig { + getDocMenuGroup: ( + query: string, + action: SearchDocMenuAction, + abortSignal: AbortSignal + ) => LinkedMenuGroup; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts index c2c2eb126ba6b..f4f2bdddb2761 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts @@ -1,4 +1,5 @@ import type { AIError } from '@blocksuite/affine/blocks'; +import type { Signal } from '@preact/signals-core'; export type ChatMessage = { id: string; @@ -44,8 +45,6 @@ export type ChatContextValue = { images: File[]; // chips of workspace doc or user uploaded file chips: ChatChip[]; - // content of selected workspace doc - docs: DocContext[]; abortController: AbortController | null; chatSessionId: string | null; }; @@ -77,6 +76,7 @@ export interface BaseChip { export interface DocChip extends BaseChip { docId: string; + content?: Signal; } export interface FileChip extends BaseChip { diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-chips.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-chips.ts index 966ce99f3dc9c..6f6809a411f62 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-chips.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-chips.ts @@ -2,23 +2,43 @@ import { type EditorHost, ShadowlessElement, } from '@blocksuite/affine/block-std'; +import { createLitPortal } from '@blocksuite/affine/blocks'; import { WithDisposable } from '@blocksuite/affine/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { flip, offset } from '@floating-ui/dom'; import { css, html } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, query } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; -import type { DocDisplayConfig } from './chat-config'; -import type { ChatContextValue } from './chat-context'; +import type { DocDisplayConfig, DocSearchMenuConfig } from './chat-config'; +import type { BaseChip, ChatChip, ChatContextValue } from './chat-context'; import { getChipKey, isDocChip, isFileChip } from './components/utils'; export class ChatPanelChips extends WithDisposable(ShadowlessElement) { static override styles = css` - .chip-list { + .chips-wrapper { display: flex; flex-wrap: wrap; } + .add-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid var(--affine-border-color); + border-radius: 4px; + margin: 4px 0; + box-sizing: border-box; + cursor: pointer; + } + .add-button:hover { + background-color: var(--affine-hover-color); + } `; + private _abortController: AbortController | null = null; + @property({ attribute: false }) accessor host!: EditorHost; @@ -31,8 +51,17 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor docDisplayConfig!: DocDisplayConfig; + @property({ attribute: false }) + accessor docSearchMenuConfig!: DocSearchMenuConfig; + + @query('.add-button') + accessor addButton!: HTMLDivElement; + override render() { - return html`
+ return html`
+
+ ${PlusIcon()} +
${repeat( this.chatContextValue.chips, chip => getChipKey(chip), @@ -40,10 +69,10 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) { if (isDocChip(chip)) { return html``; } if (isFileChip(chip)) { @@ -56,4 +85,97 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) { )}
`; } + + private readonly _toggleAddDocMenu = () => { + if (this._abortController) { + this._abortController.abort(); + return; + } + + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this._abortController = null; + }); + + createLitPortal({ + template: html` + + `, + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + container: document.body, + computePosition: { + referenceElement: this.addButton, + placement: 'top-start', + middleware: [offset({ crossAxis: -30, mainAxis: 10 }), flip()], + autoUpdate: { animationFrame: true }, + }, + abortController: this._abortController, + closeOnClickAway: true, + }); + }; + + private readonly _addChip = (chip: ChatChip) => { + if ( + this.chatContextValue.chips.length === 1 && + this.chatContextValue.chips[0].state === 'candidate' + ) { + this.updateContext({ + chips: [chip], + }); + return; + } + // remove the chip if it already exists + const chips = this.chatContextValue.chips.filter(item => { + if (isDocChip(item)) { + return !isDocChip(chip) || item.docId !== chip.docId; + } else { + return !isFileChip(chip) || item.fileId !== chip.fileId; + } + }); + this.updateContext({ + chips: [...chips, chip], + }); + }; + + private readonly _updateChip = ( + chip: ChatChip, + options: Partial + ) => { + const index = this.chatContextValue.chips.findIndex(item => { + if (isDocChip(chip)) { + return isDocChip(item) && item.docId === chip.docId; + } else { + return isFileChip(item) && item.fileId === chip.fileId; + } + }); + const nextChip: ChatChip = { + ...chip, + ...options, + }; + this.updateContext({ + chips: [ + ...this.chatContextValue.chips.slice(0, index), + nextChip, + ...this.chatContextValue.chips.slice(index + 1), + ], + }); + }; + + private readonly _removeChip = (chip: ChatChip) => { + this.updateContext({ + chips: this.chatContextValue.chips.filter(item => { + if (isDocChip(item)) { + return !isDocChip(chip) || item.docId !== chip.docId; + } else { + return !isFileChip(chip) || item.fileId !== chip.fileId; + } + }), + }); + }; } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts index 225e8d55fb84d..2b47b6904994c 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts @@ -26,6 +26,7 @@ import { reportResponse } from '../utils/action-reporter'; import { readBlobAsURL } from '../utils/image'; import type { AINetworkSearchConfig } from './chat-config'; import type { ChatContextValue, ChatMessage } from './chat-context'; +import { isDocChip } from './components/utils'; const MaximumImageCount = 32; @@ -507,7 +508,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) { }; send = async (text: string) => { - const { status, markdown, docs } = this.chatContextValue; + const { status, markdown, chips } = this.chatContextValue; if (status === 'loading' || status === 'transmitting') return; const { images } = this.chatContextValue; @@ -516,6 +517,11 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) { } const { doc } = this.host; + const docsContent = chips + .filter(isDocChip) + .map(chip => chip.content?.value || '') + .join('\n'); + this.updateContext({ images: [], status: 'loading', @@ -528,8 +534,8 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) { images?.map(image => readBlobAsURL(image)) ); - const refDocs = docs.map(doc => doc.markdown).join('\n'); - const content = (markdown ? `${markdown}\n` : '') + `${refDocs}\n` + text; + const content = + (markdown ? `${markdown}\n` : '') + `${docsContent}\n` + text; this.updateContext({ items: [ diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/add-popover.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/add-popover.ts new file mode 100644 index 0000000000000..7c43624e7b90a --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/add-popover.ts @@ -0,0 +1,157 @@ +import { ShadowlessElement } from '@blocksuite/affine/block-std'; +import { + type LinkedMenuGroup, + scrollbarStyle, +} from '@blocksuite/affine/blocks'; +import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/utils'; +import { SearchIcon } from '@blocksuite/icons/lit'; +import type { DocMeta } from '@blocksuite/store'; +import { css, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { DocSearchMenuConfig } from '../chat-config'; +import type { ChatChip } from '../chat-context'; + +export class ChatPanelAddPopover extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .add-popover { + width: 280px; + max-height: 240px; + overflow-y: auto; + border: 0.5px solid var(--affine-border-color); + border-radius: 4px; + background: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-2); + padding: 8px; + } + .add-popover icon-button { + justify-content: flex-start; + gap: 8px; + } + .add-popover icon-button svg { + width: 20px; + height: 20px; + } + .add-popover .divider { + border-top: 0.5px solid var(--affine-border-color); + margin: 8px 0; + } + .search-input-wrapper { + display: flex; + align-items: center; + gap: 6px; + padding: 4px; + } + .search-input-wrapper input { + border: none; + line-height: 20px; + height: 20px; + font-size: var(--affine-font-sm); + color: var(--affine-text-primary-color); + flex-grow: 1; + } + .search-input-wrapper input::placeholder { + color: var(--affine-placeholder-color); + } + .search-input-wrapper input:focus { + outline: none; + } + .search-input-wrapper svg { + width: 20px; + height: 20px; + color: var(--affine-v2-icon-primary); + } + .no-result { + padding: 4px; + font-size: var(--affine-font-sm); + color: var(--affine-text-secondary-color); + } + + ${scrollbarStyle('.add-popover')} + `; + + @state() + private accessor _query = ''; + + @state() + private accessor _docGroup: LinkedMenuGroup = { + name: 'No Result', + items: [], + }; + + @state() + private accessor _activatedItemIndex = 0; + + @property({ attribute: false }) + accessor docSearchMenuConfig!: DocSearchMenuConfig; + + @property({ attribute: false }) + accessor addChip!: (chip: ChatChip) => void; + + @property({ attribute: false }) + accessor abortController!: AbortController; + + override connectedCallback() { + super.connectedCallback(); + this._updateDocGroup(); + } + + override render() { + const items = Array.isArray(this._docGroup.items) + ? this._docGroup.items + : this._docGroup.items.value; + return html`
+
+ ${SearchIcon()} + +
+
+
+ ${items.length > 0 + ? items.map(({ key, name, icon, action }, curIdx) => { + return html` action()?.catch(console.error)} + @mousemove=${() => (this._activatedItemIndex = curIdx)} + > + ${icon} + `; + }) + : html`
No Result
`} +
+
`; + } + + private _onInput(event: Event) { + this._query = (event.target as HTMLInputElement).value; + this._updateDocGroup(); + } + + private _updateDocGroup() { + this._docGroup = this.docSearchMenuConfig.getDocMenuGroup( + this._query, + this._addDocChip, + this.abortController.signal + ); + } + + private readonly _addDocChip = (meta: DocMeta) => { + this.addChip({ + docId: meta.id, + state: 'embedding', + }); + this.abortController.abort(); + }; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/chip.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/chip.ts index d4f21d81d8a62..7c4c9de3cacd1 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/chip.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/chip.ts @@ -12,13 +12,15 @@ export class ChatPanelChip extends SignalWatcher( static override styles = css` .chip-card { display: flex; + height: 24px; align-items: center; justify-content: center; - padding: 4px; margin: 4px; + padding: 0 4px; border-radius: 4px; - border: 0.5px solid var(--affine-border-color); + border: 1px solid var(--affine-border-color); background: var(--affine-background-primary-color); + box-sizing: border-box; } .chip-card[data-state='candidate'] { border-width: 0.5px; @@ -46,6 +48,8 @@ export class ChatPanelChip extends SignalWatcher( overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .chip-card[data-state='candidate'] .chip-card-title { cursor: pointer; } .chip-card-close { diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/doc-chip.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/doc-chip.ts index e56934af2b5bc..1be6a0b44b713 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/doc-chip.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/doc-chip.ts @@ -2,15 +2,21 @@ import { type EditorHost, ShadowlessElement, } from '@blocksuite/affine/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/utils'; +import { + SignalWatcher, + throttle, + WithDisposable, +} from '@blocksuite/affine/global/utils'; import { Signal } from '@preact/signals-core'; -import { html } from 'lit'; +import { html, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; import { extractMarkdownFromDoc } from '../../utils/extract'; import type { DocDisplayConfig } from '../chat-config'; -import type { ChatContextValue, DocChip } from '../chat-context'; -import { getChipIcon, getChipTooltip, isDocChip } from './utils'; +import type { BaseChip, ChatChip, DocChip } from '../chat-context'; +import { getChipIcon, getChipTooltip } from './utils'; + +const EXTRACT_DOC_THROTTLE = 1000; export class ChatPanelDocChip extends SignalWatcher( WithDisposable(ShadowlessElement) @@ -19,91 +25,95 @@ export class ChatPanelDocChip extends SignalWatcher( accessor chip!: DocChip; @property({ attribute: false }) - accessor docDisplayConfig!: DocDisplayConfig; + accessor updateChip!: (chip: ChatChip, options: Partial) => void; @property({ attribute: false }) - accessor host!: EditorHost; + accessor removeChip!: (chip: ChatChip) => void; @property({ attribute: false }) - accessor chatContextValue!: ChatContextValue; + accessor docDisplayConfig!: DocDisplayConfig; @property({ attribute: false }) - accessor updateContext!: (context: Partial) => void; - - private name = new Signal(''); + accessor host!: EditorHost; - private cleanup?: () => any; + private chipName = new Signal(''); override connectedCallback() { super.connectedCallback(); + const { signal, cleanup } = this.docDisplayConfig.getTitle(this.chip.docId); - this.name = signal; - this.cleanup = cleanup; + this.chipName = signal; + this.disposables.add(cleanup); + + const doc = this.docDisplayConfig.getDoc(this.chip.docId); + if (doc) { + this.disposables.add( + doc.slots.blockUpdated.on( + throttle(this.autoUpdateChip, EXTRACT_DOC_THROTTLE) + ) + ); + this.autoUpdateChip(); + } + } + + override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if ( + changedProperties.has('chip') && + changedProperties.get('chip')?.state === 'candidate' && + this.chip.state === 'embedding' + ) { + this.embedDocChip().catch(console.error); + } } override disconnectedCallback() { super.disconnectedCallback(); - this.cleanup?.(); + this.disposables.dispose(); } - private readonly onChipClick = () => { + private readonly onChipClick = async () => { if (this.chip.state === 'candidate') { - const doc = this.docDisplayConfig.getDoc(this.chip.docId); - if (!doc) { - return; - } - this.updateChipContext({ + this.updateChip(this.chip, { state: 'embedding', }); - extractMarkdownFromDoc(doc, this.host.std.provider) - .then(result => { - this.updateChipContext({ - state: 'success', - }); - this.updateContext({ - docs: [...this.chatContextValue.docs, result], - }); - }) - .catch(e => { - this.updateChipContext({ - state: 'failed', - tooltip: e.message, - }); - }); } }; - private updateChipContext(options: Partial) { - const index = this.chatContextValue.chips.findIndex(item => { - return isDocChip(item) && item.docId === this.chip.docId; - }); - const nextChip: DocChip = { - ...this.chip, - ...options, - }; - this.updateContext({ - chips: [ - ...this.chatContextValue.chips.slice(0, index), - nextChip, - ...this.chatContextValue.chips.slice(index + 1), - ], - }); - } - private readonly onChipDelete = () => { - if (this.chip.state === 'success') { - this.updateContext({ - docs: this.chatContextValue.docs.filter( - doc => doc.docId !== this.chip.docId - ), - }); + this.removeChip(this.chip); + }; + + private readonly autoUpdateChip = () => { + if (this.chip.state !== 'candidate') { + this.embedDocChip().catch(console.error); } + }; - this.updateContext({ - chips: this.chatContextValue.chips.filter( - chip => isDocChip(chip) && chip.docId !== this.chip.docId - ), - }); + private readonly embedDocChip = async () => { + try { + const doc = this.docDisplayConfig.getDoc(this.chip.docId); + if (!doc) { + throw new Error('Document not found'); + } + if (!doc.ready) { + doc.load(); + } + const result = await extractMarkdownFromDoc(doc, this.host.std.provider); + if (this.chip.content) { + this.chip.content.value = result.markdown; + } else { + this.chip.content = new Signal(result.markdown); + } + this.updateChip(this.chip, { + state: 'success', + }); + } catch (e) { + this.updateChip(this.chip, { + state: 'failed', + tooltip: e instanceof Error ? e.message : 'Failed to embed document', + }); + } }; override render() { @@ -112,11 +122,15 @@ export class ChatPanelDocChip extends SignalWatcher( const getIcon = this.docDisplayConfig.getIcon(docId); const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon; const icon = getChipIcon(state, docIcon); - const tooltip = getChipTooltip(state, this.name.value, this.chip.tooltip); + const tooltip = getChipTooltip( + state, + this.chipName.value, + this.chip.tooltip + ); return html` { +): Promise<{ docId: string; markdown: string }> { const transformer = await getTransformer(doc); const adapter = new MarkdownAdapter(transformer, provider); const blockModels = getNoteBlockModels(doc); diff --git a/packages/frontend/core/src/blocksuite/presets/effects.ts b/packages/frontend/core/src/blocksuite/presets/effects.ts index fa8f6d1dee189..0c07c63c95cbf 100644 --- a/packages/frontend/core/src/blocksuite/presets/effects.ts +++ b/packages/frontend/core/src/blocksuite/presets/effects.ts @@ -18,6 +18,7 @@ import { AILoading } from './ai/chat-panel/ai-loading'; import { ChatPanelChips } from './ai/chat-panel/chat-panel-chips'; import { ChatPanelInput } from './ai/chat-panel/chat-panel-input'; import { ChatPanelMessages } from './ai/chat-panel/chat-panel-messages'; +import { ChatPanelAddPopover } from './ai/chat-panel/components/add-popover'; import { ChatPanelChip } from './ai/chat-panel/components/chip'; import { ChatPanelDocChip } from './ai/chat-panel/components/doc-chip'; import { ChatPanelFileChip } from './ai/chat-panel/components/file-chip'; @@ -62,6 +63,7 @@ export function registerBlocksuitePresetsCustomComponents() { customElements.define('chat-panel-messages', ChatPanelMessages); customElements.define('chat-panel', ChatPanel); customElements.define('chat-panel-chips', ChatPanelChips); + customElements.define('chat-panel-add-popover', ChatPanelAddPopover); customElements.define('chat-panel-doc-chip', ChatPanelDocChip); customElements.define('chat-panel-file-chip', ChatPanelFileChip); customElements.define('chat-panel-chip', ChatPanelChip); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index 5400a1990b38b..131488c9af98e 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -1,6 +1,7 @@ import { ChatPanel } from '@affine/core/blocksuite/presets/ai'; import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { DocSearchMenuService } from '@affine/core/modules/doc-search-menu/services'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { createSignalFromObservable, @@ -54,6 +55,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( const searchService = framework.get(AINetworkSearchService); const docDisplayMetaService = framework.get(DocDisplayMetaService); const workspaceService = framework.get(WorkspaceService); + const docSearchMenuService = framework.get(DocSearchMenuService); chatPanelRef.current.networkSearchConfig = { visible: searchService.visible, enabled: searchService.enabled, @@ -72,6 +74,15 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( return doc; }, }; + chatPanelRef.current.docSearchMenuConfig = { + getDocMenuGroup: (query, action, abortSignal) => { + return docSearchMenuService.getDocMenuGroup( + query, + action, + abortSignal + ); + }, + }; } else { chatPanelRef.current.host = editor.host; chatPanelRef.current.doc = editor.doc; diff --git a/packages/frontend/core/src/modules/at-menu-config/index.ts b/packages/frontend/core/src/modules/at-menu-config/index.ts index eca4fe7d3d4a2..17fe8b6749f65 100644 --- a/packages/frontend/core/src/modules/at-menu-config/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/index.ts @@ -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, ]); } diff --git a/packages/frontend/core/src/modules/at-menu-config/services/index.ts b/packages/frontend/core/src/modules/at-menu-config/services/index.ts index 27d8e20b96059..44da1bb8de2f4 100644 --- a/packages/frontend/core/src/modules/at-menu-config/services/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/services/index.ts @@ -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: ``, - end: '', - }, - ], - }, - } - ) - .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], - }; - }) - ) - ); - } } diff --git a/packages/frontend/core/src/modules/doc-search-menu/index.ts b/packages/frontend/core/src/modules/doc-search-menu/index.ts new file mode 100644 index 0000000000000..8c883fd40b6b3 --- /dev/null +++ b/packages/frontend/core/src/modules/doc-search-menu/index.ts @@ -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, + ]); +} diff --git a/packages/frontend/core/src/modules/doc-search-menu/services/index.ts b/packages/frontend/core/src/modules/doc-search-menu/services/index.ts new file mode 100644 index 0000000000000..350555660b98f --- /dev/null +++ b/packages/frontend/core/src/modules/doc-search-menu/services/index.ts @@ -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; + +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: ``, + end: '', + }, + ], + }, + } + ) + .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); + }, + }; + } +} diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 649cd840c88d8..db0d34cfb6930 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -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);