mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00: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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
packages/frontend/core/src/modules/doc-search-menu/index.ts
Normal file
18
packages/frontend/core/src/modules/doc-search-menu/index.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -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