From aefbc11aab7cadde85f639c49fec6e7adcbd9550 Mon Sep 17 00:00:00 2001 From: akumatus Date: Tue, 25 Mar 2025 15:50:11 +0000 Subject: [PATCH] feat(core): add candidates popover in ai chat-panel (#11178) Close [BS-2853](https://linear.app/affine-design/issue/BS-2853). --- .../blocksuite/ai/chat-panel/chat-config.ts | 3 +- .../ai/chat-panel/chat-panel-chips.ts | 77 ++++++++- .../ai/chat-panel/components/add-popover.ts | 9 +- .../components/candidates-popover.ts | 150 ++++++++++++++++++ .../ai/chat-panel/components/chip.ts | 7 +- .../ai/chat-panel/components/doc-chip.ts | 4 +- .../core/src/blocksuite/ai/effects.ts | 5 + .../pages/workspace/detail-page/tabs/chat.tsx | 3 + .../affine-cloud-copilot/e2e/copilot.spec.ts | 2 +- 9 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/ai/chat-panel/components/candidates-popover.ts diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts index 95bf18d5d3..7a1576abbe 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts @@ -28,7 +28,8 @@ export interface AINetworkSearchConfig { export interface DocDisplayConfig { getIcon: (docId: string) => any; - getTitle: (docId: string) => { + getTitle: (docId: string) => string; + getTitleSignal: (docId: string) => { signal: Signal; cleanup: () => void; }; diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-chips.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-chips.ts index 580c42a516..0fe5b39314 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-chips.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-chips.ts @@ -6,9 +6,9 @@ import { } from '@blocksuite/affine/block-std'; import { createLitPortal } from '@blocksuite/affine/components/portal'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; -import { PlusIcon } from '@blocksuite/icons/lit'; +import { MoreVerticalIcon, PlusIcon } from '@blocksuite/icons/lit'; import { flip, offset } from '@floating-ui/dom'; -import { type Signal, signal } from '@preact/signals-core'; +import { computed, type Signal, signal } from '@preact/signals-core'; import { css, html, nothing, type PropertyValues } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; @@ -36,6 +36,8 @@ import { // 100k tokens limit for the docs context const MAX_TOKEN_COUNT = 100000; +const MAX_CANDIDATES = 3; + export class ChatPanelChips extends SignalWatcher( WithDisposable(ShadowlessElement) ) { @@ -45,13 +47,14 @@ export class ChatPanelChips extends SignalWatcher( flex-wrap: wrap; } .add-button, - .collapse-button { + .collapse-button, + .more-candidate-button { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; - border: 1px solid var(--affine-border-color); + border: 0.5px solid var(--affine-border-color); border-radius: 4px; margin: 4px 0; box-sizing: border-box; @@ -62,6 +65,15 @@ export class ChatPanelChips extends SignalWatcher( .collapse-button:hover { background-color: var(--affine-hover-color); } + .more-candidate-button { + border-width: 1px; + border-style: dashed; + background: var(--affine-background-secondary-color); + color: var(--affine-icon-secondary); + } + .more-candidate-button svg { + color: var(--affine-icon-secondary); + } `; private _abortController: AbortController | null = null; @@ -90,6 +102,9 @@ export class ChatPanelChips extends SignalWatcher( @query('.add-button') accessor addButton!: HTMLDivElement; + @query('.more-candidate-button') + accessor moreCandidateButton!: HTMLDivElement; + @state() accessor isCollapsed = false; @@ -114,11 +129,14 @@ export class ChatPanelChips extends SignalWatcher( docId: doc.docId, state: 'candidate', })); - const allChips = this.chatContextValue.chips.concat(candidates); + const moreCandidates = candidates.length > MAX_CANDIDATES; + const allChips = this.chatContextValue.chips.concat( + candidates.slice(0, MAX_CANDIDATES) + ); const isCollapsed = this.isCollapsed && allChips.length > 1; const chips = isCollapsed ? allChips.slice(0, 1) : allChips; - return html`
+ return html`
${PlusIcon()}
@@ -170,6 +188,14 @@ export class ChatPanelChips extends SignalWatcher( return null; } )} + ${moreCandidates && !isCollapsed + ? html`
+ ${MoreVerticalIcon()} +
` + : nothing} ${isCollapsed ? html`
+${allChips.length - 1} @@ -247,6 +273,45 @@ export class ChatPanelChips extends SignalWatcher( }); }; + private readonly _toggleMoreCandidatesMenu = () => { + if (this._abortController) { + this._abortController.abort(); + return; + } + + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this._abortController = null; + }); + + const referenceDocs = computed(() => + this.referenceDocs.value.slice(MAX_CANDIDATES) + ); + + createLitPortal({ + template: html` + + `, + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + container: document.body, + computePosition: { + referenceElement: this.moreCandidateButton, + placement: 'top-start', + middleware: [offset({ crossAxis: 0, mainAxis: 6 }), flip()], + autoUpdate: { animationFrame: true }, + }, + abortController: this._abortController, + closeOnClickAway: true, + }); + }; + private readonly _addChip = async (chip: ChatChip) => { this.isCollapsed = false; // remove the chip if it already exists diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts index e870522704..28fb49d842 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts @@ -43,7 +43,6 @@ export type MenuItem = { name: string | TemplateResult<1>; icon: TemplateResult<1>; action: MenuAction; - suffix?: string | TemplateResult<1>; }; export type MenuAction = () => Promise | void; @@ -111,11 +110,6 @@ export class ChatPanelAddPopover extends SignalWatcher( .menu-items icon-button { outline: none; } - .item-suffix { - margin-left: auto; - font-size: var(--affine-font-xs); - color: var(--affine-text-secondary-color); - } ${scrollbarStyle('.add-popover')} `; @@ -335,7 +329,7 @@ export class ChatPanelAddPopover extends SignalWatcher( ${repeat( items, item => item.key, - ({ key, name, icon, action, suffix }, idx) => { + ({ key, name, icon, action }, idx) => { const curIdx = startIndex + idx; return html` (this._activatedIndex = curIdx)} > ${icon} - ${suffix ? html`
${suffix}
` : ''}
`; } )} diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/candidates-popover.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/candidates-popover.ts new file mode 100644 index 0000000000..709c077fc4 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/candidates-popover.ts @@ -0,0 +1,150 @@ +import { ShadowlessElement } from '@blocksuite/affine/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; +import { scrollbarStyle } from '@blocksuite/affine/shared/styles'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { type Signal, signal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { DocDisplayConfig } from '../chat-config'; +import type { DocChip } from '../chat-context'; + +export class ChatPanelCandidatesPopover extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .candidates-popover { + width: 280px; + max-height: 450px; + 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; + } + + .candidates-popover icon-button { + justify-content: flex-start; + gap: 8px; + } + .candidates-popover icon-button svg { + width: 20px; + height: 20px; + color: var(--svg-icon-color); + } + + ${scrollbarStyle('.candidates-popover')} + `; + + @property({ attribute: false }) + accessor referenceDocs: Signal< + Array<{ + docId: string; + title: string; + }> + > = signal([]); + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor addChip!: (chip: DocChip) => void; + + @property({ attribute: false }) + accessor docDisplayConfig!: DocDisplayConfig; + + @state() + private accessor _activatedIndex = 0; + + override connectedCallback() { + super.connectedCallback(); + document.addEventListener('keydown', this._handleKeyDown); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('keydown', this._handleKeyDown); + } + + override render() { + return html`
+ ${repeat( + this.referenceDocs.value, + doc => doc.docId, + (doc, curIdx) => { + const { docId } = doc; + const title = this.docDisplayConfig.getTitle(docId); + const getIcon = this.docDisplayConfig.getIcon(docId); + const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon; + + return html`
+ this._addDocChip(docId)} + @mousemove=${() => (this._activatedIndex = curIdx)} + > + ${docIcon} + ${PlusIcon()} + +
`; + } + )} +
`; + } + + private readonly _addDocChip = (docId: string) => { + this.addChip({ + docId, + state: 'processing', + }); + }; + + private readonly _handleKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; + + const { key } = event; + if (key === 'ArrowDown' || key === 'ArrowUp') { + event.preventDefault(); + const totalItems = this.referenceDocs.value.length; + if (totalItems === 0) return; + + if (key === 'ArrowDown') { + this._activatedIndex = (this._activatedIndex + 1) % totalItems; + } else if (key === 'ArrowUp') { + this._activatedIndex = + (this._activatedIndex - 1 + totalItems) % totalItems; + } + this._scrollItemIntoView(); + } else if (key === 'Enter') { + event.preventDefault(); + if (this.referenceDocs.value.length > 0) { + const docId = this.referenceDocs.value[this._activatedIndex].docId; + this._addDocChip(docId); + } + } else if (key === 'Escape') { + event.preventDefault(); + this.abortController.abort(); + } + }; + + private _scrollItemIntoView() { + requestAnimationFrame(() => { + const element = this.renderRoot.querySelector( + `[data-index="${this._activatedIndex}"]` + ); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }); + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/chip.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/chip.ts index 977753c7b0..96023baeec 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/chip.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/chip.ts @@ -25,7 +25,7 @@ export class ChatPanelChip extends SignalWatcher( .chip-card[data-state='candidate'] { border-width: 1px; border-style: dashed; - background: var(--affine-tag-white); + background: var(--affine-background-secondary-color); color: var(--affine-icon-secondary); } .chip-card[data-state='candidate'] svg { @@ -58,7 +58,7 @@ export class ChatPanelChip extends SignalWatcher( text-overflow: ellipsis; white-space: nowrap; } - .chip-card[data-state='candidate'] .chip-card-title { + .chip-card[data-state='candidate'] { cursor: pointer; } .chip-card-close { @@ -101,10 +101,11 @@ export class ChatPanelChip extends SignalWatcher( class="chip-card" data-testid="chat-panel-chip" data-state=${this.state} + @click=${this.onChipClick} >
${this.icon} - + ${this.name} ${this.tooltip} diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/doc-chip.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/doc-chip.ts index 0163ed5756..a60a2797e1 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/doc-chip.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/doc-chip.ts @@ -47,7 +47,9 @@ export class ChatPanelDocChip extends SignalWatcher( override connectedCallback() { super.connectedCallback(); - const { signal, cleanup } = this.docDisplayConfig.getTitle(this.chip.docId); + const { signal, cleanup } = this.docDisplayConfig.getTitleSignal( + this.chip.docId + ); this.chipName = signal; this.disposables.add(cleanup); diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index db797dbe2f..bae038ec45 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -27,6 +27,7 @@ import { ChatPanelChips } from './chat-panel/chat-panel-chips'; import { ChatPanelInput } from './chat-panel/chat-panel-input'; import { ChatPanelMessages } from './chat-panel/chat-panel-messages'; import { ChatPanelAddPopover } from './chat-panel/components/add-popover'; +import { ChatPanelCandidatesPopover } from './chat-panel/components/candidates-popover'; import { ChatPanelChip } from './chat-panel/components/chip'; import { ChatPanelCollectionChip } from './chat-panel/components/collection-chip'; import { ChatPanelDocChip } from './chat-panel/components/doc-chip'; @@ -100,6 +101,10 @@ export function registerAIEffects() { customElements.define('chat-panel', ChatPanel); customElements.define('chat-panel-chips', ChatPanelChips); customElements.define('chat-panel-add-popover', ChatPanelAddPopover); + customElements.define( + 'chat-panel-candidates-popover', + ChatPanelCandidatesPopover + ); customElements.define('chat-panel-doc-chip', ChatPanelDocChip); customElements.define('chat-panel-file-chip', ChatPanelFileChip); customElements.define('chat-panel-tag-chip', ChatPanelTagChip); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index ee32fcab99..93c9e8435a 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -88,6 +88,9 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( return docDisplayMetaService.icon$(docId, { type: 'lit' }).value; }, getTitle: (docId: string) => { + return docDisplayMetaService.title$(docId).value; + }, + getTitleSignal: (docId: string) => { const title$ = docDisplayMetaService.title$(docId); return createSignalFromObservable(title$, ''); }, diff --git a/tests/affine-cloud-copilot/e2e/copilot.spec.ts b/tests/affine-cloud-copilot/e2e/copilot.spec.ts index 79b2cd47c8..fe45969567 100644 --- a/tests/affine-cloud-copilot/e2e/copilot.spec.ts +++ b/tests/affine-cloud-copilot/e2e/copilot.spec.ts @@ -272,7 +272,7 @@ test.describe('chat panel', () => { await page.waitForTimeout(200); await createLocalWorkspace({ name: 'test' }, page); await clickNewPageButton(page); - await makeChat(page, 'hello'); + await makeChat(page, 'What is AFFiNE?'); const content = (await collectChat(page))[1].content; await page.getByTestId('action-copy-button').click(); await page.waitForTimeout(500);