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:
akumatus
2025-01-21 11:20:18 +00:00
parent ba508ffd6b
commit a3164b4ccf
19 changed files with 691 additions and 294 deletions

View File

@@ -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;
}

View File

@@ -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<string>;
}
export interface FileChip extends BaseChip {

View File

@@ -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`<div class="chip-list">
return html` <div class="chips-wrapper">
<div class="add-button" @click=${this._toggleAddDocMenu}>
${PlusIcon()}
</div>
${repeat(
this.chatContextValue.chips,
chip => getChipKey(chip),
@@ -40,10 +69,10 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
if (isDocChip(chip)) {
return html`<chat-panel-doc-chip
.chip=${chip}
.updateChip=${this._updateChip}
.removeChip=${this._removeChip}
.docDisplayConfig=${this.docDisplayConfig}
.host=${this.host}
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
></chat-panel-doc-chip>`;
}
if (isFileChip(chip)) {
@@ -56,4 +85,97 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
)}
</div>`;
}
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`
<chat-panel-add-popover
.addChip=${this._addChip}
.docSearchMenuConfig=${this.docSearchMenuConfig}
.abortController=${this._abortController}
></chat-panel-add-popover>
`,
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<BaseChip>
) => {
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;
}
}),
});
};
}

View File

@@ -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: [

View File

@@ -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`<div class="add-popover">
<div class="search-input-wrapper">
${SearchIcon()}
<input
class="search-input"
type="text"
placeholder="Search Doc"
.value=${this._query}
@input=${this._onInput}
/>
</div>
<div class="divider"></div>
<div class="search-group" style=${this._docGroup.styles ?? ''}>
${items.length > 0
? items.map(({ key, name, icon, action }, curIdx) => {
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
.text=${name}
hover=${this._activatedItemIndex === curIdx}
@click=${() => action()?.catch(console.error)}
@mousemove=${() => (this._activatedItemIndex = curIdx)}
>
${icon}
</icon-button>`;
})
: html`<div class="no-result">No Result</div>`}
</div>
</div>`;
}
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();
};
}

View File

@@ -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 {

View File

@@ -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)
@@ -18,92 +24,96 @@ export class ChatPanelDocChip extends SignalWatcher(
@property({ attribute: false })
accessor chip!: DocChip;
@property({ attribute: false })
accessor updateChip!: (chip: ChatChip, options: Partial<BaseChip>) => void;
@property({ attribute: false })
accessor removeChip!: (chip: ChatChip) => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor chatContextValue!: ChatContextValue;
@property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
private name = new Signal<string>('');
private cleanup?: () => any;
private chipName = new Signal<string>('');
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<DocChip>) {
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);
}
};
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<string>(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',
});
}
this.updateContext({
chips: this.chatContextValue.chips.filter(
chip => isDocChip(chip) && chip.docId !== this.chip.docId
),
});
};
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`<chat-panel-chip
.state=${state}
.name=${this.name.value}
.name=${this.chipName.value}
.tooltip=${tooltip}
.icon=${icon}
.closeable=${!isLoading}

View File

@@ -6,7 +6,7 @@ import type { ChatChip, ChipState, DocChip, FileChip } from '../chat-context';
export function getChipTooltip(
state: ChipState,
title: string,
name: string,
tooltip?: string
) {
if (tooltip) {
@@ -24,7 +24,7 @@ export function getChipTooltip(
if (state === 'failed') {
return 'Failed to embed';
}
return title;
return name;
}
export function getChipIcon(

View File

@@ -17,7 +17,11 @@ import {
getSelectedImagesAsBlobs,
getSelectedTextContent,
} from '../utils/selection-utils';
import type { AINetworkSearchConfig, DocDisplayConfig } from './chat-config';
import type {
AINetworkSearchConfig,
DocDisplayConfig,
DocSearchMenuConfig,
} from './chat-config';
import type {
ChatAction,
ChatContextValue,
@@ -161,6 +165,9 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor docSearchMenuConfig!: DocSearchMenuConfig;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@@ -174,7 +181,6 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
abortController: null,
items: [],
chips: [],
docs: [],
status: 'idle',
error: null,
markdown: '',
@@ -218,7 +224,6 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
this.chatContextValue.chatSessionId = null;
// TODO get from CopilotContext
this.chatContextValue.chips = [];
this.chatContextValue.docs = [];
this._resetItems();
});
}
@@ -307,6 +312,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
.docDisplayConfig=${this.docDisplayConfig}
.docSearchMenuConfig=${this.docSearchMenuConfig}
></chat-panel-chips>
<chat-panel-input
.chatContextValue=${this.chatContextValue}

View File

@@ -14,7 +14,7 @@ import type { ServiceProvider } from '@blocksuite/affine/global/di';
import type { BlockModel, Store } from '@blocksuite/affine/store';
import { Slice, toDraftModel, Transformer } from '@blocksuite/affine/store';
import type { ChatContextValue, DocContext } from '../chat-panel/chat-context';
import type { ChatContextValue } from '../chat-panel/chat-context';
import {
allToCanvas,
getSelectedImagesAsBlobs,
@@ -156,7 +156,7 @@ export async function extractPageAll(
export async function extractMarkdownFromDoc(
doc: Store,
provider: ServiceProvider
): Promise<DocContext> {
): Promise<{ docId: string; markdown: string }> {
const transformer = await getTransformer(doc);
const adapter = new MarkdownAdapter(transformer, provider);
const blockModels = getNoteBlockModels(doc);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,
]);
}

View File

@@ -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],
};
})
)
);
}
}

View File

@@ -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,
]);
}

View File

@@ -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);
},
};
}
}

View File

@@ -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);