Skip to content

Commit

Permalink
feat(core): support ai doc search panel (#9831)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
akumatus committed Jan 21, 2025
1 parent ba508ff commit a3164b4
Show file tree
Hide file tree
Showing 19 changed files with 689 additions and 292 deletions.
1 change: 1 addition & 0 deletions blocksuite/blocks/src/root-block/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { AffineImageToolbarWidget } from './image-toolbar/index.js';
export { AffineInnerModalWidget } from './inner-modal/inner-modal.js';
export * from './keyboard-toolbar/index.js';
export {
type LinkedMenuAction,
type LinkedMenuGroup,
type LinkedMenuItem,
type LinkedWidgetConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ export type LinkedMenuItem = {
icon: TemplateResult<1>;
suffix?: string | TemplateResult<1>;
// disabled?: boolean;
action: () => Promise<void> | void;
action: LinkedMenuAction;
};

export type LinkedMenuAction = () => Promise<void> | void;

export type LinkedMenuGroup = {
name: string;
items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -15,3 +17,11 @@ export interface DocDisplayConfig {
};
getDoc: (docId: string) => Store | null;
}

export interface DocSearchMenuConfig {
getDocMenuGroup: (
query: string,
action: SearchDocMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AIError } from '@blocksuite/affine/blocks';
import type { Signal } from '@preact/signals-core';

export type ChatMessage = {
id: string;
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -77,6 +76,7 @@ export interface BaseChip {

export interface DocChip extends BaseChip {
docId: string;
content?: Signal<string>;
}

export interface FileChip extends BaseChip {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -31,19 +51,28 @@ 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),
chip => {
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)) {
Expand All @@ -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;
}
}),
});
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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',
Expand All @@ -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: [
Expand Down
Loading

0 comments on commit a3164b4

Please sign in to comment.