mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08:00
feat(core): support ai doc search panel (#9831)
Support issue [BS-2351](https://linear.app/affine-design/issue/BS-2351) and [BS-2461](https://linear.app/affine-design/issue/BS-2461). ## What changed? - Add `chat-panel-add-popover` component. - Refactor part of `AtMenuConfigService` into `DocSearchMenuService`. - Add signal `content` property to `DocChip` interface for markdown content update. <div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov">录屏2025-01-21 18.46.29.mov</video>
This commit is contained in:
@@ -30,6 +30,7 @@ export { AffineImageToolbarWidget } from './image-toolbar/index.js';
|
|||||||
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
|
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
|
||||||
export * from './keyboard-toolbar/index.js';
|
export * from './keyboard-toolbar/index.js';
|
||||||
export {
|
export {
|
||||||
|
type LinkedMenuAction,
|
||||||
type LinkedMenuGroup,
|
type LinkedMenuGroup,
|
||||||
type LinkedMenuItem,
|
type LinkedMenuItem,
|
||||||
type LinkedWidgetConfig,
|
type LinkedWidgetConfig,
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ export type LinkedMenuItem = {
|
|||||||
icon: TemplateResult<1>;
|
icon: TemplateResult<1>;
|
||||||
suffix?: string | TemplateResult<1>;
|
suffix?: string | TemplateResult<1>;
|
||||||
// disabled?: boolean;
|
// disabled?: boolean;
|
||||||
action: () => Promise<void> | void;
|
action: LinkedMenuAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LinkedMenuAction = () => Promise<void> | void;
|
||||||
|
|
||||||
export type LinkedMenuGroup = {
|
export type LinkedMenuGroup = {
|
||||||
name: string;
|
name: string;
|
||||||
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
|
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
|
||||||
|
|||||||
@@ -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 { Store } from '@blocksuite/affine/store';
|
||||||
import type { Signal } from '@preact/signals-core';
|
import type { Signal } from '@preact/signals-core';
|
||||||
|
|
||||||
@@ -15,3 +17,11 @@ export interface DocDisplayConfig {
|
|||||||
};
|
};
|
||||||
getDoc: (docId: string) => Store | null;
|
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 { AIError } from '@blocksuite/affine/blocks';
|
||||||
|
import type { Signal } from '@preact/signals-core';
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,8 +45,6 @@ export type ChatContextValue = {
|
|||||||
images: File[];
|
images: File[];
|
||||||
// chips of workspace doc or user uploaded file
|
// chips of workspace doc or user uploaded file
|
||||||
chips: ChatChip[];
|
chips: ChatChip[];
|
||||||
// content of selected workspace doc
|
|
||||||
docs: DocContext[];
|
|
||||||
abortController: AbortController | null;
|
abortController: AbortController | null;
|
||||||
chatSessionId: string | null;
|
chatSessionId: string | null;
|
||||||
};
|
};
|
||||||
@@ -77,6 +76,7 @@ export interface BaseChip {
|
|||||||
|
|
||||||
export interface DocChip extends BaseChip {
|
export interface DocChip extends BaseChip {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
content?: Signal<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileChip extends BaseChip {
|
export interface FileChip extends BaseChip {
|
||||||
|
|||||||
@@ -2,23 +2,43 @@ import {
|
|||||||
type EditorHost,
|
type EditorHost,
|
||||||
ShadowlessElement,
|
ShadowlessElement,
|
||||||
} from '@blocksuite/affine/block-std';
|
} from '@blocksuite/affine/block-std';
|
||||||
|
import { createLitPortal } from '@blocksuite/affine/blocks';
|
||||||
import { WithDisposable } from '@blocksuite/affine/global/utils';
|
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 { 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 { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
import type { DocDisplayConfig } from './chat-config';
|
import type { DocDisplayConfig, DocSearchMenuConfig } from './chat-config';
|
||||||
import type { ChatContextValue } from './chat-context';
|
import type { BaseChip, ChatChip, ChatContextValue } from './chat-context';
|
||||||
import { getChipKey, isDocChip, isFileChip } from './components/utils';
|
import { getChipKey, isDocChip, isFileChip } from './components/utils';
|
||||||
|
|
||||||
export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
|
export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
.chip-list {
|
.chips-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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 })
|
@property({ attribute: false })
|
||||||
accessor host!: EditorHost;
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
@@ -31,8 +51,17 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor docSearchMenuConfig!: DocSearchMenuConfig;
|
||||||
|
|
||||||
|
@query('.add-button')
|
||||||
|
accessor addButton!: HTMLDivElement;
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`<div class="chip-list">
|
return html` <div class="chips-wrapper">
|
||||||
|
<div class="add-button" @click=${this._toggleAddDocMenu}>
|
||||||
|
${PlusIcon()}
|
||||||
|
</div>
|
||||||
${repeat(
|
${repeat(
|
||||||
this.chatContextValue.chips,
|
this.chatContextValue.chips,
|
||||||
chip => getChipKey(chip),
|
chip => getChipKey(chip),
|
||||||
@@ -40,10 +69,10 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
|
|||||||
if (isDocChip(chip)) {
|
if (isDocChip(chip)) {
|
||||||
return html`<chat-panel-doc-chip
|
return html`<chat-panel-doc-chip
|
||||||
.chip=${chip}
|
.chip=${chip}
|
||||||
|
.updateChip=${this._updateChip}
|
||||||
|
.removeChip=${this._removeChip}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
.chatContextValue=${this.chatContextValue}
|
|
||||||
.updateContext=${this.updateContext}
|
|
||||||
></chat-panel-doc-chip>`;
|
></chat-panel-doc-chip>`;
|
||||||
}
|
}
|
||||||
if (isFileChip(chip)) {
|
if (isFileChip(chip)) {
|
||||||
@@ -56,4 +85,97 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
|
|||||||
)}
|
)}
|
||||||
</div>`;
|
</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 { readBlobAsURL } from '../utils/image';
|
||||||
import type { AINetworkSearchConfig } from './chat-config';
|
import type { AINetworkSearchConfig } from './chat-config';
|
||||||
import type { ChatContextValue, ChatMessage } from './chat-context';
|
import type { ChatContextValue, ChatMessage } from './chat-context';
|
||||||
|
import { isDocChip } from './components/utils';
|
||||||
|
|
||||||
const MaximumImageCount = 32;
|
const MaximumImageCount = 32;
|
||||||
|
|
||||||
@@ -507,7 +508,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
send = async (text: string) => {
|
send = async (text: string) => {
|
||||||
const { status, markdown, docs } = this.chatContextValue;
|
const { status, markdown, chips } = this.chatContextValue;
|
||||||
if (status === 'loading' || status === 'transmitting') return;
|
if (status === 'loading' || status === 'transmitting') return;
|
||||||
|
|
||||||
const { images } = this.chatContextValue;
|
const { images } = this.chatContextValue;
|
||||||
@@ -516,6 +517,11 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
}
|
}
|
||||||
const { doc } = this.host;
|
const { doc } = this.host;
|
||||||
|
|
||||||
|
const docsContent = chips
|
||||||
|
.filter(isDocChip)
|
||||||
|
.map(chip => chip.content?.value || '')
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
this.updateContext({
|
this.updateContext({
|
||||||
images: [],
|
images: [],
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
@@ -528,8 +534,8 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
images?.map(image => readBlobAsURL(image))
|
images?.map(image => readBlobAsURL(image))
|
||||||
);
|
);
|
||||||
|
|
||||||
const refDocs = docs.map(doc => doc.markdown).join('\n');
|
const content =
|
||||||
const content = (markdown ? `${markdown}\n` : '') + `${refDocs}\n` + text;
|
(markdown ? `${markdown}\n` : '') + `${docsContent}\n` + text;
|
||||||
|
|
||||||
this.updateContext({
|
this.updateContext({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
+157
@@ -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`
|
static override styles = css`
|
||||||
.chip-card {
|
.chip-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
height: 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4px;
|
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
border-radius: 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);
|
background: var(--affine-background-primary-color);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.chip-card[data-state='candidate'] {
|
.chip-card[data-state='candidate'] {
|
||||||
border-width: 0.5px;
|
border-width: 0.5px;
|
||||||
@@ -46,6 +48,8 @@ export class ChatPanelChip extends SignalWatcher(
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip-card[data-state='candidate'] .chip-card-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.chip-card-close {
|
.chip-card-close {
|
||||||
|
|||||||
+81
-67
@@ -2,15 +2,21 @@ import {
|
|||||||
type EditorHost,
|
type EditorHost,
|
||||||
ShadowlessElement,
|
ShadowlessElement,
|
||||||
} from '@blocksuite/affine/block-std';
|
} 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 { Signal } from '@preact/signals-core';
|
||||||
import { html } from 'lit';
|
import { html, type PropertyValues } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import { extractMarkdownFromDoc } from '../../utils/extract';
|
import { extractMarkdownFromDoc } from '../../utils/extract';
|
||||||
import type { DocDisplayConfig } from '../chat-config';
|
import type { DocDisplayConfig } from '../chat-config';
|
||||||
import type { ChatContextValue, DocChip } from '../chat-context';
|
import type { BaseChip, ChatChip, DocChip } from '../chat-context';
|
||||||
import { getChipIcon, getChipTooltip, isDocChip } from './utils';
|
import { getChipIcon, getChipTooltip } from './utils';
|
||||||
|
|
||||||
|
const EXTRACT_DOC_THROTTLE = 1000;
|
||||||
|
|
||||||
export class ChatPanelDocChip extends SignalWatcher(
|
export class ChatPanelDocChip extends SignalWatcher(
|
||||||
WithDisposable(ShadowlessElement)
|
WithDisposable(ShadowlessElement)
|
||||||
@@ -18,92 +24,96 @@ export class ChatPanelDocChip extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor chip!: DocChip;
|
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 })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor host!: EditorHost;
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
@property({ attribute: false })
|
private chipName = new Signal<string>('');
|
||||||
accessor chatContextValue!: ChatContextValue;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
|
||||||
|
|
||||||
private name = new Signal<string>('');
|
|
||||||
|
|
||||||
private cleanup?: () => any;
|
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
const { signal, cleanup } = this.docDisplayConfig.getTitle(this.chip.docId);
|
const { signal, cleanup } = this.docDisplayConfig.getTitle(this.chip.docId);
|
||||||
this.name = signal;
|
this.chipName = signal;
|
||||||
this.cleanup = cleanup;
|
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() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.cleanup?.();
|
this.disposables.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly onChipClick = () => {
|
private readonly onChipClick = async () => {
|
||||||
if (this.chip.state === 'candidate') {
|
if (this.chip.state === 'candidate') {
|
||||||
const doc = this.docDisplayConfig.getDoc(this.chip.docId);
|
this.updateChip(this.chip, {
|
||||||
if (!doc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.updateChipContext({
|
|
||||||
state: 'embedding',
|
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 = () => {
|
private readonly onChipDelete = () => {
|
||||||
if (this.chip.state === 'success') {
|
this.removeChip(this.chip);
|
||||||
this.updateContext({
|
};
|
||||||
docs: this.chatContextValue.docs.filter(
|
|
||||||
doc => doc.docId !== this.chip.docId
|
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() {
|
override render() {
|
||||||
@@ -112,11 +122,15 @@ export class ChatPanelDocChip extends SignalWatcher(
|
|||||||
const getIcon = this.docDisplayConfig.getIcon(docId);
|
const getIcon = this.docDisplayConfig.getIcon(docId);
|
||||||
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
|
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
|
||||||
const icon = getChipIcon(state, docIcon);
|
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
|
return html`<chat-panel-chip
|
||||||
.state=${state}
|
.state=${state}
|
||||||
.name=${this.name.value}
|
.name=${this.chipName.value}
|
||||||
.tooltip=${tooltip}
|
.tooltip=${tooltip}
|
||||||
.icon=${icon}
|
.icon=${icon}
|
||||||
.closeable=${!isLoading}
|
.closeable=${!isLoading}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ChatChip, ChipState, DocChip, FileChip } from '../chat-context';
|
|||||||
|
|
||||||
export function getChipTooltip(
|
export function getChipTooltip(
|
||||||
state: ChipState,
|
state: ChipState,
|
||||||
title: string,
|
name: string,
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
) {
|
) {
|
||||||
if (tooltip) {
|
if (tooltip) {
|
||||||
@@ -24,7 +24,7 @@ export function getChipTooltip(
|
|||||||
if (state === 'failed') {
|
if (state === 'failed') {
|
||||||
return 'Failed to embed';
|
return 'Failed to embed';
|
||||||
}
|
}
|
||||||
return title;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChipIcon(
|
export function getChipIcon(
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
getSelectedImagesAsBlobs,
|
getSelectedImagesAsBlobs,
|
||||||
getSelectedTextContent,
|
getSelectedTextContent,
|
||||||
} from '../utils/selection-utils';
|
} from '../utils/selection-utils';
|
||||||
import type { AINetworkSearchConfig, DocDisplayConfig } from './chat-config';
|
import type {
|
||||||
|
AINetworkSearchConfig,
|
||||||
|
DocDisplayConfig,
|
||||||
|
DocSearchMenuConfig,
|
||||||
|
} from './chat-config';
|
||||||
import type {
|
import type {
|
||||||
ChatAction,
|
ChatAction,
|
||||||
ChatContextValue,
|
ChatContextValue,
|
||||||
@@ -161,6 +165,9 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor networkSearchConfig!: AINetworkSearchConfig;
|
accessor networkSearchConfig!: AINetworkSearchConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor docSearchMenuConfig!: DocSearchMenuConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
@@ -174,7 +181,6 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
|
|||||||
abortController: null,
|
abortController: null,
|
||||||
items: [],
|
items: [],
|
||||||
chips: [],
|
chips: [],
|
||||||
docs: [],
|
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
markdown: '',
|
markdown: '',
|
||||||
@@ -218,7 +224,6 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
|
|||||||
this.chatContextValue.chatSessionId = null;
|
this.chatContextValue.chatSessionId = null;
|
||||||
// TODO get from CopilotContext
|
// TODO get from CopilotContext
|
||||||
this.chatContextValue.chips = [];
|
this.chatContextValue.chips = [];
|
||||||
this.chatContextValue.docs = [];
|
|
||||||
this._resetItems();
|
this._resetItems();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -307,6 +312,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
|
|||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
.updateContext=${this.updateContext}
|
.updateContext=${this.updateContext}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
|
.docSearchMenuConfig=${this.docSearchMenuConfig}
|
||||||
></chat-panel-chips>
|
></chat-panel-chips>
|
||||||
<chat-panel-input
|
<chat-panel-input
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { ServiceProvider } from '@blocksuite/affine/global/di';
|
|||||||
import type { BlockModel, Store } from '@blocksuite/affine/store';
|
import type { BlockModel, Store } from '@blocksuite/affine/store';
|
||||||
import { Slice, toDraftModel, Transformer } 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 {
|
import {
|
||||||
allToCanvas,
|
allToCanvas,
|
||||||
getSelectedImagesAsBlobs,
|
getSelectedImagesAsBlobs,
|
||||||
@@ -156,7 +156,7 @@ export async function extractPageAll(
|
|||||||
export async function extractMarkdownFromDoc(
|
export async function extractMarkdownFromDoc(
|
||||||
doc: Store,
|
doc: Store,
|
||||||
provider: ServiceProvider
|
provider: ServiceProvider
|
||||||
): Promise<DocContext> {
|
): Promise<{ docId: string; markdown: string }> {
|
||||||
const transformer = await getTransformer(doc);
|
const transformer = await getTransformer(doc);
|
||||||
const adapter = new MarkdownAdapter(transformer, provider);
|
const adapter = new MarkdownAdapter(transformer, provider);
|
||||||
const blockModels = getNoteBlockModels(doc);
|
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 { ChatPanelChips } from './ai/chat-panel/chat-panel-chips';
|
||||||
import { ChatPanelInput } from './ai/chat-panel/chat-panel-input';
|
import { ChatPanelInput } from './ai/chat-panel/chat-panel-input';
|
||||||
import { ChatPanelMessages } from './ai/chat-panel/chat-panel-messages';
|
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 { ChatPanelChip } from './ai/chat-panel/components/chip';
|
||||||
import { ChatPanelDocChip } from './ai/chat-panel/components/doc-chip';
|
import { ChatPanelDocChip } from './ai/chat-panel/components/doc-chip';
|
||||||
import { ChatPanelFileChip } from './ai/chat-panel/components/file-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-messages', ChatPanelMessages);
|
||||||
customElements.define('chat-panel', ChatPanel);
|
customElements.define('chat-panel', ChatPanel);
|
||||||
customElements.define('chat-panel-chips', ChatPanelChips);
|
customElements.define('chat-panel-chips', ChatPanelChips);
|
||||||
|
customElements.define('chat-panel-add-popover', ChatPanelAddPopover);
|
||||||
customElements.define('chat-panel-doc-chip', ChatPanelDocChip);
|
customElements.define('chat-panel-doc-chip', ChatPanelDocChip);
|
||||||
customElements.define('chat-panel-file-chip', ChatPanelFileChip);
|
customElements.define('chat-panel-file-chip', ChatPanelFileChip);
|
||||||
customElements.define('chat-panel-chip', ChatPanelChip);
|
customElements.define('chat-panel-chip', ChatPanelChip);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search';
|
import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search';
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
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 { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import {
|
import {
|
||||||
createSignalFromObservable,
|
createSignalFromObservable,
|
||||||
@@ -54,6 +55,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
|||||||
const searchService = framework.get(AINetworkSearchService);
|
const searchService = framework.get(AINetworkSearchService);
|
||||||
const docDisplayMetaService = framework.get(DocDisplayMetaService);
|
const docDisplayMetaService = framework.get(DocDisplayMetaService);
|
||||||
const workspaceService = framework.get(WorkspaceService);
|
const workspaceService = framework.get(WorkspaceService);
|
||||||
|
const docSearchMenuService = framework.get(DocSearchMenuService);
|
||||||
chatPanelRef.current.networkSearchConfig = {
|
chatPanelRef.current.networkSearchConfig = {
|
||||||
visible: searchService.visible,
|
visible: searchService.visible,
|
||||||
enabled: searchService.enabled,
|
enabled: searchService.enabled,
|
||||||
@@ -72,6 +74,15 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
|||||||
return doc;
|
return doc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
chatPanelRef.current.docSearchMenuConfig = {
|
||||||
|
getDocMenuGroup: (query, action, abortSignal) => {
|
||||||
|
return docSearchMenuService.getDocMenuGroup(
|
||||||
|
query,
|
||||||
|
action,
|
||||||
|
abortSignal
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
chatPanelRef.current.host = editor.host;
|
chatPanelRef.current.host = editor.host;
|
||||||
chatPanelRef.current.doc = editor.doc;
|
chatPanelRef.current.doc = editor.doc;
|
||||||
|
|||||||
@@ -3,24 +3,21 @@ import { type Framework } from '@toeverything/infra';
|
|||||||
import { WorkspaceDialogService } from '../dialogs';
|
import { WorkspaceDialogService } from '../dialogs';
|
||||||
import { DocsService } from '../doc';
|
import { DocsService } from '../doc';
|
||||||
import { DocDisplayMetaService } from '../doc-display-meta';
|
import { DocDisplayMetaService } from '../doc-display-meta';
|
||||||
import { DocsSearchService } from '../docs-search';
|
import { DocSearchMenuService } from '../doc-search-menu/services';
|
||||||
import { EditorSettingService } from '../editor-setting';
|
import { EditorSettingService } from '../editor-setting';
|
||||||
import { JournalService } from '../journal';
|
import { JournalService } from '../journal';
|
||||||
import { RecentDocsService } from '../quicksearch';
|
import { WorkspaceScope } from '../workspace';
|
||||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
|
||||||
import { AtMenuConfigService } from './services';
|
import { AtMenuConfigService } from './services';
|
||||||
|
|
||||||
export function configAtMenuConfigModule(framework: Framework) {
|
export function configAtMenuConfigModule(framework: Framework) {
|
||||||
framework
|
framework
|
||||||
.scope(WorkspaceScope)
|
.scope(WorkspaceScope)
|
||||||
.service(AtMenuConfigService, [
|
.service(AtMenuConfigService, [
|
||||||
WorkspaceService,
|
|
||||||
JournalService,
|
JournalService,
|
||||||
DocDisplayMetaService,
|
DocDisplayMetaService,
|
||||||
WorkspaceDialogService,
|
WorkspaceDialogService,
|
||||||
RecentDocsService,
|
|
||||||
EditorSettingService,
|
EditorSettingService,
|
||||||
DocsService,
|
DocsService,
|
||||||
DocsSearchService,
|
DocSearchMenuService,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { fuzzyMatch } from '@affine/core/utils/fuzzy-match';
|
|
||||||
import { I18n, i18nTime } from '@affine/i18n';
|
import { I18n, i18nTime } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
import track from '@affine/track';
|
||||||
import type { EditorHost } from '@blocksuite/affine/block-std';
|
import type { EditorHost } from '@blocksuite/affine/block-std';
|
||||||
import {
|
import {
|
||||||
type AffineInlineEditor,
|
type AffineInlineEditor,
|
||||||
createSignalFromObservable,
|
|
||||||
type DocMode,
|
type DocMode,
|
||||||
type LinkedMenuGroup,
|
type LinkedMenuGroup,
|
||||||
type LinkedMenuItem,
|
type LinkedMenuItem,
|
||||||
@@ -22,29 +20,22 @@ import { computed } from '@preact/signals-core';
|
|||||||
import { Service } from '@toeverything/infra';
|
import { Service } from '@toeverything/infra';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
|
||||||
import { map } from 'rxjs';
|
|
||||||
|
|
||||||
import type { WorkspaceDialogService } from '../../dialogs';
|
import type { WorkspaceDialogService } from '../../dialogs';
|
||||||
import type { DocsService } from '../../doc';
|
import type { DocsService } from '../../doc';
|
||||||
import type { DocDisplayMetaService } from '../../doc-display-meta';
|
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 { EditorSettingService } from '../../editor-setting';
|
||||||
import { type JournalService, suggestJournalDate } from '../../journal';
|
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 {
|
export class AtMenuConfigService extends Service {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly workspaceService: WorkspaceService,
|
|
||||||
private readonly journalService: JournalService,
|
private readonly journalService: JournalService,
|
||||||
private readonly docDisplayMetaService: DocDisplayMetaService,
|
private readonly docDisplayMetaService: DocDisplayMetaService,
|
||||||
private readonly dialogService: WorkspaceDialogService,
|
private readonly dialogService: WorkspaceDialogService,
|
||||||
private readonly recentDocsService: RecentDocsService,
|
|
||||||
private readonly editorSettingService: EditorSettingService,
|
private readonly editorSettingService: EditorSettingService,
|
||||||
private readonly docsService: DocsService,
|
private readonly docsService: DocsService,
|
||||||
private readonly docsSearch: DocsSearchService
|
private readonly docsSearchMenuService: DocSearchMenuService
|
||||||
) {
|
) {
|
||||||
super();
|
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(
|
private newDocMenuGroup(
|
||||||
query: string,
|
query: string,
|
||||||
close: () => void,
|
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'] {
|
private getMenusFn(): LinkedWidgetConfig['getMenus'] {
|
||||||
return (query, close, editorHost, inlineEditor, abortSignal) => {
|
return (query, close, editorHost, inlineEditor, abortSignal) => {
|
||||||
return [
|
return [
|
||||||
@@ -422,49 +299,4 @@ export class AtMenuConfigService extends Service {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// only search docs by title, excluding blocks
|
|
||||||
private searchDocs$(query: string) {
|
|
||||||
return this.docsSearch.indexer.docIndex
|
|
||||||
.aggregate$(
|
|
||||||
{
|
|
||||||
type: 'boolean',
|
|
||||||
occur: 'must',
|
|
||||||
queries: [
|
|
||||||
{
|
|
||||||
type: 'match',
|
|
||||||
field: 'title',
|
|
||||||
match: query,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'docId',
|
|
||||||
{
|
|
||||||
hits: {
|
|
||||||
fields: ['docId', 'title'],
|
|
||||||
pagination: {
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
highlights: [
|
|
||||||
{
|
|
||||||
field: 'title',
|
|
||||||
before: `<span style="color: ${cssVarV2('text/emphasis')}">`,
|
|
||||||
end: '</span>',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
map(({ buckets }) =>
|
|
||||||
buckets.map(bucket => {
|
|
||||||
return {
|
|
||||||
id: bucket.key,
|
|
||||||
title: bucket.hits.nodes[0].fields.title,
|
|
||||||
highlights: bucket.hits.nodes[0].highlights.title[0],
|
|
||||||
};
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { DocDisplayMetaService } from '../doc-display-meta';
|
||||||
|
import { DocsSearchService } from '../docs-search';
|
||||||
|
import { RecentDocsService } from '../quicksearch';
|
||||||
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
|
import { DocSearchMenuService } from './services';
|
||||||
|
|
||||||
|
export function configDocSearchMenuModule(framework: Framework) {
|
||||||
|
framework
|
||||||
|
.scope(WorkspaceScope)
|
||||||
|
.service(DocSearchMenuService, [
|
||||||
|
WorkspaceService,
|
||||||
|
DocDisplayMetaService,
|
||||||
|
RecentDocsService,
|
||||||
|
DocsSearchService,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { fuzzyMatch } from '@affine/core/utils/fuzzy-match';
|
||||||
|
import { I18n } from '@affine/i18n';
|
||||||
|
import type {
|
||||||
|
LinkedMenuGroup,
|
||||||
|
LinkedMenuItem,
|
||||||
|
} from '@blocksuite/affine/blocks';
|
||||||
|
import { createSignalFromObservable } from '@blocksuite/affine/blocks';
|
||||||
|
import type { DocMeta } from '@blocksuite/affine/store';
|
||||||
|
import { computed } from '@preact/signals-core';
|
||||||
|
import { Service } from '@toeverything/infra';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { html } from 'lit';
|
||||||
|
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
|
||||||
|
import type { DocDisplayMetaService } from '../../doc-display-meta';
|
||||||
|
import type { DocsSearchService } from '../../docs-search';
|
||||||
|
import type { RecentDocsService } from '../../quicksearch';
|
||||||
|
import type { WorkspaceService } from '../../workspace';
|
||||||
|
|
||||||
|
const MAX_DOCS = 3;
|
||||||
|
|
||||||
|
type DocMetaWithHighlights = DocMeta & {
|
||||||
|
highlights?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchDocMenuAction = (meta: DocMeta) => Promise<void> | void;
|
||||||
|
|
||||||
|
export class DocSearchMenuService extends Service {
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceService: WorkspaceService,
|
||||||
|
private readonly docDisplayMetaService: DocDisplayMetaService,
|
||||||
|
private readonly recentDocsService: RecentDocsService,
|
||||||
|
private readonly docsSearch: DocsSearchService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocMenuGroup(
|
||||||
|
query: string,
|
||||||
|
action: SearchDocMenuAction,
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
): LinkedMenuGroup {
|
||||||
|
const showRecent = query.trim().length === 0;
|
||||||
|
if (showRecent) {
|
||||||
|
return this.getRecentDocMenuGroup(action);
|
||||||
|
} else {
|
||||||
|
return this.getSearchDocMenuGroup(query, action, abortSignal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRecentDocMenuGroup(action: SearchDocMenuAction): LinkedMenuGroup {
|
||||||
|
const currentWorkspace = this.workspaceService.workspace;
|
||||||
|
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
|
||||||
|
const recentDocs = this.recentDocsService.getRecentDocs();
|
||||||
|
return {
|
||||||
|
name: I18n.t('com.affine.editor.at-menu.recent-docs'),
|
||||||
|
items: recentDocs
|
||||||
|
.map(doc => {
|
||||||
|
const meta = rawMetas.find(meta => meta.id === doc.id);
|
||||||
|
if (!meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDocMenuItem(meta, action);
|
||||||
|
})
|
||||||
|
.filter(m => !!m),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSearchDocMenuGroup(
|
||||||
|
query: string,
|
||||||
|
action: SearchDocMenuAction,
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
): LinkedMenuGroup {
|
||||||
|
const currentWorkspace = this.workspaceService.workspace;
|
||||||
|
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
|
||||||
|
const { signal: docsSignal, cleanup: cleanupDocs } =
|
||||||
|
createSignalFromObservable(
|
||||||
|
this.searchDocs$(query).pipe(
|
||||||
|
map(result => {
|
||||||
|
const docs = result
|
||||||
|
.map(doc => {
|
||||||
|
const meta = rawMetas.find(meta => meta.id === doc.id);
|
||||||
|
if (!meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const highlights =
|
||||||
|
'highlights' in doc ? doc.highlights : undefined;
|
||||||
|
return this.toDocMenuItem(
|
||||||
|
{
|
||||||
|
...meta,
|
||||||
|
highlights,
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(m => !!m);
|
||||||
|
return docs;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { signal: isIndexerLoading, cleanup: cleanupIndexerLoading } =
|
||||||
|
createSignalFromObservable(
|
||||||
|
this.docsSearch.indexer.status$.pipe(
|
||||||
|
map(status => status.remaining !== undefined && status.remaining > 0)
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const overflowText = computed(() => {
|
||||||
|
const overflowCount = docsSignal.value.length - MAX_DOCS;
|
||||||
|
return I18n.t('com.affine.editor.at-menu.more-docs-hint', {
|
||||||
|
count: overflowCount > 100 ? '100+' : overflowCount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
abortSignal.addEventListener('abort', () => {
|
||||||
|
cleanupDocs();
|
||||||
|
cleanupIndexerLoading();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: I18n.t('com.affine.editor.at-menu.link-to-doc', {
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
loading: isIndexerLoading,
|
||||||
|
loadingText: I18n.t('com.affine.editor.at-menu.loading'),
|
||||||
|
items: docsSignal,
|
||||||
|
maxDisplay: MAX_DOCS,
|
||||||
|
overflowText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// only search docs by title, excluding blocks
|
||||||
|
private searchDocs$(query: string) {
|
||||||
|
return this.docsSearch.indexer.docIndex
|
||||||
|
.aggregate$(
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
occur: 'must',
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
type: 'match',
|
||||||
|
field: 'title',
|
||||||
|
match: query,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'docId',
|
||||||
|
{
|
||||||
|
hits: {
|
||||||
|
fields: ['docId', 'title'],
|
||||||
|
pagination: {
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
{
|
||||||
|
field: 'title',
|
||||||
|
before: `<span style="color: ${cssVarV2('text/emphasis')}">`,
|
||||||
|
end: '</span>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
map(({ buckets }) =>
|
||||||
|
buckets.map(bucket => {
|
||||||
|
return {
|
||||||
|
id: bucket.key,
|
||||||
|
title: bucket.hits.nodes[0].fields.title,
|
||||||
|
highlights: bucket.hits.nodes[0].highlights.title[0],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDocMenuItem(
|
||||||
|
meta: DocMetaWithHighlights,
|
||||||
|
action: SearchDocMenuAction,
|
||||||
|
query?: string
|
||||||
|
): LinkedMenuItem | null {
|
||||||
|
const title = this.docDisplayMetaService.title$(meta.id, {
|
||||||
|
reference: true,
|
||||||
|
}).value;
|
||||||
|
|
||||||
|
if (meta.trash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && !fuzzyMatch(title, query)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: meta.highlights ? html`${unsafeHTML(meta.highlights)}` : title,
|
||||||
|
key: meta.id,
|
||||||
|
icon: this.docDisplayMetaService
|
||||||
|
.icon$(meta.id, {
|
||||||
|
type: 'lit',
|
||||||
|
reference: true,
|
||||||
|
})
|
||||||
|
.value(),
|
||||||
|
action: async () => {
|
||||||
|
await action(meta);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { configureDocModule } from './doc';
|
|||||||
import { configureDocDisplayMetaModule } from './doc-display-meta';
|
import { configureDocDisplayMetaModule } from './doc-display-meta';
|
||||||
import { configureDocInfoModule } from './doc-info';
|
import { configureDocInfoModule } from './doc-info';
|
||||||
import { configureDocLinksModule } from './doc-link';
|
import { configureDocLinksModule } from './doc-link';
|
||||||
|
import { configDocSearchMenuModule } from './doc-search-menu';
|
||||||
import { configureDocsSearchModule } from './docs-search';
|
import { configureDocsSearchModule } from './docs-search';
|
||||||
import { configureEditorModule } from './editor';
|
import { configureEditorModule } from './editor';
|
||||||
import { configureEditorSettingModule } from './editor-setting';
|
import { configureEditorSettingModule } from './editor-setting';
|
||||||
@@ -91,6 +92,7 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureDocInfoModule(framework);
|
configureDocInfoModule(framework);
|
||||||
configureOpenInApp(framework);
|
configureOpenInApp(framework);
|
||||||
configAtMenuConfigModule(framework);
|
configAtMenuConfigModule(framework);
|
||||||
|
configDocSearchMenuModule(framework);
|
||||||
configureDndModule(framework);
|
configureDndModule(framework);
|
||||||
configureCommonGlobalStorageImpls(framework);
|
configureCommonGlobalStorageImpls(framework);
|
||||||
configureAINetworkSearchModule(framework);
|
configureAINetworkSearchModule(framework);
|
||||||
|
|||||||
Reference in New Issue
Block a user