feat(core): support ai doc search panel (#9831)

Support issue [BS-2351](https://linear.app/affine-design/issue/BS-2351) and [BS-2461](https://linear.app/affine-design/issue/BS-2461).

## What changed?
- Add `chat-panel-add-popover` component.
- Refactor part of `AtMenuConfigService` into `DocSearchMenuService`.
- Add signal `content` property to `DocChip` interface for markdown content update.

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/ff1d69b3-edd6-4d33-a01d-8b16e5192af7.mov">录屏2025-01-21 18.46.29.mov</video>
This commit is contained in:
akumatus
2025-01-21 11:20:18 +00:00
parent ba508ffd6b
commit a3164b4ccf
19 changed files with 691 additions and 294 deletions

View File

@@ -1,3 +1,5 @@
import type { SearchDocMenuAction } from '@affine/core/modules/doc-search-menu/services';
import type { LinkedMenuGroup } from '@blocksuite/affine/blocks';
import type { Store } from '@blocksuite/affine/store';
import type { Signal } from '@preact/signals-core';
@@ -15,3 +17,11 @@ export interface DocDisplayConfig {
};
getDoc: (docId: string) => Store | null;
}
export interface DocSearchMenuConfig {
getDocMenuGroup: (
query: string,
action: SearchDocMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
}

View File

@@ -1,4 +1,5 @@
import type { AIError } from '@blocksuite/affine/blocks';
import type { Signal } from '@preact/signals-core';
export type ChatMessage = {
id: string;
@@ -44,8 +45,6 @@ export type ChatContextValue = {
images: File[];
// chips of workspace doc or user uploaded file
chips: ChatChip[];
// content of selected workspace doc
docs: DocContext[];
abortController: AbortController | null;
chatSessionId: string | null;
};
@@ -77,6 +76,7 @@ export interface BaseChip {
export interface DocChip extends BaseChip {
docId: string;
content?: Signal<string>;
}
export interface FileChip extends BaseChip {

View File

@@ -2,23 +2,43 @@ import {
type EditorHost,
ShadowlessElement,
} from '@blocksuite/affine/block-std';
import { createLitPortal } from '@blocksuite/affine/blocks';
import { WithDisposable } from '@blocksuite/affine/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { DocDisplayConfig } from './chat-config';
import type { ChatContextValue } from './chat-context';
import type { DocDisplayConfig, DocSearchMenuConfig } from './chat-config';
import type { BaseChip, ChatChip, ChatContextValue } from './chat-context';
import { getChipKey, isDocChip, isFileChip } from './components/utils';
export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
static override styles = css`
.chip-list {
.chips-wrapper {
display: flex;
flex-wrap: wrap;
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--affine-border-color);
border-radius: 4px;
margin: 4px 0;
box-sizing: border-box;
cursor: pointer;
}
.add-button:hover {
background-color: var(--affine-hover-color);
}
`;
private _abortController: AbortController | null = null;
@property({ attribute: false })
accessor host!: EditorHost;
@@ -31,8 +51,17 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor docSearchMenuConfig!: DocSearchMenuConfig;
@query('.add-button')
accessor addButton!: HTMLDivElement;
override render() {
return html`<div class="chip-list">
return html` <div class="chips-wrapper">
<div class="add-button" @click=${this._toggleAddDocMenu}>
${PlusIcon()}
</div>
${repeat(
this.chatContextValue.chips,
chip => getChipKey(chip),
@@ -40,10 +69,10 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
if (isDocChip(chip)) {
return html`<chat-panel-doc-chip
.chip=${chip}
.updateChip=${this._updateChip}
.removeChip=${this._removeChip}
.docDisplayConfig=${this.docDisplayConfig}
.host=${this.host}
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
></chat-panel-doc-chip>`;
}
if (isFileChip(chip)) {
@@ -56,4 +85,97 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
)}
</div>`;
}
private readonly _toggleAddDocMenu = () => {
if (this._abortController) {
this._abortController.abort();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._abortController = null;
});
createLitPortal({
template: html`
<chat-panel-add-popover
.addChip=${this._addChip}
.docSearchMenuConfig=${this.docSearchMenuConfig}
.abortController=${this._abortController}
></chat-panel-add-popover>
`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: document.body,
computePosition: {
referenceElement: this.addButton,
placement: 'top-start',
middleware: [offset({ crossAxis: -30, mainAxis: 10 }), flip()],
autoUpdate: { animationFrame: true },
},
abortController: this._abortController,
closeOnClickAway: true,
});
};
private readonly _addChip = (chip: ChatChip) => {
if (
this.chatContextValue.chips.length === 1 &&
this.chatContextValue.chips[0].state === 'candidate'
) {
this.updateContext({
chips: [chip],
});
return;
}
// remove the chip if it already exists
const chips = this.chatContextValue.chips.filter(item => {
if (isDocChip(item)) {
return !isDocChip(chip) || item.docId !== chip.docId;
} else {
return !isFileChip(chip) || item.fileId !== chip.fileId;
}
});
this.updateContext({
chips: [...chips, chip],
});
};
private readonly _updateChip = (
chip: ChatChip,
options: Partial<BaseChip>
) => {
const index = this.chatContextValue.chips.findIndex(item => {
if (isDocChip(chip)) {
return isDocChip(item) && item.docId === chip.docId;
} else {
return isFileChip(item) && item.fileId === chip.fileId;
}
});
const nextChip: ChatChip = {
...chip,
...options,
};
this.updateContext({
chips: [
...this.chatContextValue.chips.slice(0, index),
nextChip,
...this.chatContextValue.chips.slice(index + 1),
],
});
};
private readonly _removeChip = (chip: ChatChip) => {
this.updateContext({
chips: this.chatContextValue.chips.filter(item => {
if (isDocChip(item)) {
return !isDocChip(chip) || item.docId !== chip.docId;
} else {
return !isFileChip(chip) || item.fileId !== chip.fileId;
}
}),
});
};
}

View File

@@ -26,6 +26,7 @@ import { reportResponse } from '../utils/action-reporter';
import { readBlobAsURL } from '../utils/image';
import type { AINetworkSearchConfig } from './chat-config';
import type { ChatContextValue, ChatMessage } from './chat-context';
import { isDocChip } from './components/utils';
const MaximumImageCount = 32;
@@ -507,7 +508,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
};
send = async (text: string) => {
const { status, markdown, docs } = this.chatContextValue;
const { status, markdown, chips } = this.chatContextValue;
if (status === 'loading' || status === 'transmitting') return;
const { images } = this.chatContextValue;
@@ -516,6 +517,11 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
}
const { doc } = this.host;
const docsContent = chips
.filter(isDocChip)
.map(chip => chip.content?.value || '')
.join('\n');
this.updateContext({
images: [],
status: 'loading',
@@ -528,8 +534,8 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
images?.map(image => readBlobAsURL(image))
);
const refDocs = docs.map(doc => doc.markdown).join('\n');
const content = (markdown ? `${markdown}\n` : '') + `${refDocs}\n` + text;
const content =
(markdown ? `${markdown}\n` : '') + `${docsContent}\n` + text;
this.updateContext({
items: [

View File

@@ -0,0 +1,157 @@
import { ShadowlessElement } from '@blocksuite/affine/block-std';
import {
type LinkedMenuGroup,
scrollbarStyle,
} from '@blocksuite/affine/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/utils';
import { SearchIcon } from '@blocksuite/icons/lit';
import type { DocMeta } from '@blocksuite/store';
import { css, html } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { DocSearchMenuConfig } from '../chat-config';
import type { ChatChip } from '../chat-context';
export class ChatPanelAddPopover extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.add-popover {
width: 280px;
max-height: 240px;
overflow-y: auto;
border: 0.5px solid var(--affine-border-color);
border-radius: 4px;
background: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-2);
padding: 8px;
}
.add-popover icon-button {
justify-content: flex-start;
gap: 8px;
}
.add-popover icon-button svg {
width: 20px;
height: 20px;
}
.add-popover .divider {
border-top: 0.5px solid var(--affine-border-color);
margin: 8px 0;
}
.search-input-wrapper {
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
}
.search-input-wrapper input {
border: none;
line-height: 20px;
height: 20px;
font-size: var(--affine-font-sm);
color: var(--affine-text-primary-color);
flex-grow: 1;
}
.search-input-wrapper input::placeholder {
color: var(--affine-placeholder-color);
}
.search-input-wrapper input:focus {
outline: none;
}
.search-input-wrapper svg {
width: 20px;
height: 20px;
color: var(--affine-v2-icon-primary);
}
.no-result {
padding: 4px;
font-size: var(--affine-font-sm);
color: var(--affine-text-secondary-color);
}
${scrollbarStyle('.add-popover')}
`;
@state()
private accessor _query = '';
@state()
private accessor _docGroup: LinkedMenuGroup = {
name: 'No Result',
items: [],
};
@state()
private accessor _activatedItemIndex = 0;
@property({ attribute: false })
accessor docSearchMenuConfig!: DocSearchMenuConfig;
@property({ attribute: false })
accessor addChip!: (chip: ChatChip) => void;
@property({ attribute: false })
accessor abortController!: AbortController;
override connectedCallback() {
super.connectedCallback();
this._updateDocGroup();
}
override render() {
const items = Array.isArray(this._docGroup.items)
? this._docGroup.items
: this._docGroup.items.value;
return html`<div class="add-popover">
<div class="search-input-wrapper">
${SearchIcon()}
<input
class="search-input"
type="text"
placeholder="Search Doc"
.value=${this._query}
@input=${this._onInput}
/>
</div>
<div class="divider"></div>
<div class="search-group" style=${this._docGroup.styles ?? ''}>
${items.length > 0
? items.map(({ key, name, icon, action }, curIdx) => {
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
.text=${name}
hover=${this._activatedItemIndex === curIdx}
@click=${() => action()?.catch(console.error)}
@mousemove=${() => (this._activatedItemIndex = curIdx)}
>
${icon}
</icon-button>`;
})
: html`<div class="no-result">No Result</div>`}
</div>
</div>`;
}
private _onInput(event: Event) {
this._query = (event.target as HTMLInputElement).value;
this._updateDocGroup();
}
private _updateDocGroup() {
this._docGroup = this.docSearchMenuConfig.getDocMenuGroup(
this._query,
this._addDocChip,
this.abortController.signal
);
}
private readonly _addDocChip = (meta: DocMeta) => {
this.addChip({
docId: meta.id,
state: 'embedding',
});
this.abortController.abort();
};
}

View File

@@ -12,13 +12,15 @@ export class ChatPanelChip extends SignalWatcher(
static override styles = css`
.chip-card {
display: flex;
height: 24px;
align-items: center;
justify-content: center;
padding: 4px;
margin: 4px;
padding: 0 4px;
border-radius: 4px;
border: 0.5px solid var(--affine-border-color);
border: 1px solid var(--affine-border-color);
background: var(--affine-background-primary-color);
box-sizing: border-box;
}
.chip-card[data-state='candidate'] {
border-width: 0.5px;
@@ -46,6 +48,8 @@ export class ChatPanelChip extends SignalWatcher(
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-card[data-state='candidate'] .chip-card-title {
cursor: pointer;
}
.chip-card-close {

View File

@@ -2,15 +2,21 @@ import {
type EditorHost,
ShadowlessElement,
} from '@blocksuite/affine/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/utils';
import {
SignalWatcher,
throttle,
WithDisposable,
} from '@blocksuite/affine/global/utils';
import { Signal } from '@preact/signals-core';
import { html } from 'lit';
import { html, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { extractMarkdownFromDoc } from '../../utils/extract';
import type { DocDisplayConfig } from '../chat-config';
import type { ChatContextValue, DocChip } from '../chat-context';
import { getChipIcon, getChipTooltip, isDocChip } from './utils';
import type { BaseChip, ChatChip, DocChip } from '../chat-context';
import { getChipIcon, getChipTooltip } from './utils';
const EXTRACT_DOC_THROTTLE = 1000;
export class ChatPanelDocChip extends SignalWatcher(
WithDisposable(ShadowlessElement)
@@ -18,92 +24,96 @@ export class ChatPanelDocChip extends SignalWatcher(
@property({ attribute: false })
accessor chip!: DocChip;
@property({ attribute: false })
accessor updateChip!: (chip: ChatChip, options: Partial<BaseChip>) => void;
@property({ attribute: false })
accessor removeChip!: (chip: ChatChip) => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor chatContextValue!: ChatContextValue;
@property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
private name = new Signal<string>('');
private cleanup?: () => any;
private chipName = new Signal<string>('');
override connectedCallback() {
super.connectedCallback();
const { signal, cleanup } = this.docDisplayConfig.getTitle(this.chip.docId);
this.name = signal;
this.cleanup = cleanup;
this.chipName = signal;
this.disposables.add(cleanup);
const doc = this.docDisplayConfig.getDoc(this.chip.docId);
if (doc) {
this.disposables.add(
doc.slots.blockUpdated.on(
throttle(this.autoUpdateChip, EXTRACT_DOC_THROTTLE)
)
);
this.autoUpdateChip();
}
}
override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
changedProperties.has('chip') &&
changedProperties.get('chip')?.state === 'candidate' &&
this.chip.state === 'embedding'
) {
this.embedDocChip().catch(console.error);
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cleanup?.();
this.disposables.dispose();
}
private readonly onChipClick = () => {
private readonly onChipClick = async () => {
if (this.chip.state === 'candidate') {
const doc = this.docDisplayConfig.getDoc(this.chip.docId);
if (!doc) {
return;
}
this.updateChipContext({
this.updateChip(this.chip, {
state: 'embedding',
});
extractMarkdownFromDoc(doc, this.host.std.provider)
.then(result => {
this.updateChipContext({
state: 'success',
});
this.updateContext({
docs: [...this.chatContextValue.docs, result],
});
})
.catch(e => {
this.updateChipContext({
state: 'failed',
tooltip: e.message,
});
});
}
};
private updateChipContext(options: Partial<DocChip>) {
const index = this.chatContextValue.chips.findIndex(item => {
return isDocChip(item) && item.docId === this.chip.docId;
});
const nextChip: DocChip = {
...this.chip,
...options,
};
this.updateContext({
chips: [
...this.chatContextValue.chips.slice(0, index),
nextChip,
...this.chatContextValue.chips.slice(index + 1),
],
});
}
private readonly onChipDelete = () => {
if (this.chip.state === 'success') {
this.updateContext({
docs: this.chatContextValue.docs.filter(
doc => doc.docId !== this.chip.docId
),
this.removeChip(this.chip);
};
private readonly autoUpdateChip = () => {
if (this.chip.state !== 'candidate') {
this.embedDocChip().catch(console.error);
}
};
private readonly embedDocChip = async () => {
try {
const doc = this.docDisplayConfig.getDoc(this.chip.docId);
if (!doc) {
throw new Error('Document not found');
}
if (!doc.ready) {
doc.load();
}
const result = await extractMarkdownFromDoc(doc, this.host.std.provider);
if (this.chip.content) {
this.chip.content.value = result.markdown;
} else {
this.chip.content = new Signal<string>(result.markdown);
}
this.updateChip(this.chip, {
state: 'success',
});
} catch (e) {
this.updateChip(this.chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Failed to embed document',
});
}
this.updateContext({
chips: this.chatContextValue.chips.filter(
chip => isDocChip(chip) && chip.docId !== this.chip.docId
),
});
};
override render() {
@@ -112,11 +122,15 @@ export class ChatPanelDocChip extends SignalWatcher(
const getIcon = this.docDisplayConfig.getIcon(docId);
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
const icon = getChipIcon(state, docIcon);
const tooltip = getChipTooltip(state, this.name.value, this.chip.tooltip);
const tooltip = getChipTooltip(
state,
this.chipName.value,
this.chip.tooltip
);
return html`<chat-panel-chip
.state=${state}
.name=${this.name.value}
.name=${this.chipName.value}
.tooltip=${tooltip}
.icon=${icon}
.closeable=${!isLoading}

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import { AILoading } from './ai/chat-panel/ai-loading';
import { ChatPanelChips } from './ai/chat-panel/chat-panel-chips';
import { ChatPanelInput } from './ai/chat-panel/chat-panel-input';
import { ChatPanelMessages } from './ai/chat-panel/chat-panel-messages';
import { ChatPanelAddPopover } from './ai/chat-panel/components/add-popover';
import { ChatPanelChip } from './ai/chat-panel/components/chip';
import { ChatPanelDocChip } from './ai/chat-panel/components/doc-chip';
import { ChatPanelFileChip } from './ai/chat-panel/components/file-chip';
@@ -62,6 +63,7 @@ export function registerBlocksuitePresetsCustomComponents() {
customElements.define('chat-panel-messages', ChatPanelMessages);
customElements.define('chat-panel', ChatPanel);
customElements.define('chat-panel-chips', ChatPanelChips);
customElements.define('chat-panel-add-popover', ChatPanelAddPopover);
customElements.define('chat-panel-doc-chip', ChatPanelDocChip);
customElements.define('chat-panel-file-chip', ChatPanelFileChip);
customElements.define('chat-panel-chip', ChatPanelChip);