From 19f0eb1931e0dd51fb7bda11be6ba94793d6b88a Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Tue, 11 Feb 2025 15:11:54 +0000 Subject: [PATCH] fix(core): use patched preview spec builder in ai chat (#10090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [BS-2526](https://linear.app/affine-design/issue/BS-2526/chat-panel-里的-footnote-popup-需要支持交互) --- .../nodes/footnote-node/footnote-config.ts | 17 +++- .../nodes/footnote-node/footnote-node.ts | 10 +- .../nodes/footnote-node/footnote-popup.ts | 54 ++++++++--- .../ai/chat-panel/actions/chat-text.ts | 13 ++- .../ai/chat-panel/chat-panel-messages.ts | 5 + .../blocksuite/presets/ai/chat-panel/index.ts | 9 +- .../ai/peek-view/chat-block-peek-view.ts | 17 +++- .../block-suite-editor/specs/preview.ts | 92 ++++++++++++++++++- .../pages/workspace/detail-page/tabs/chat.tsx | 3 + .../view/ai-chat-block-peek-view/index.tsx | 29 ++++++ .../peek-view/view/peek-view-manager.tsx | 5 +- 11 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts index 2bc4c7fd1..5570879a7 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts @@ -15,11 +15,17 @@ type FootNotePopupRenderer = ( abortController: AbortController ) => TemplateResult<1>; +export type FootNotePopupClickHandler = ( + footnote: FootNote, + abortController: AbortController +) => void; + export interface FootNoteNodeConfig { customNodeRenderer?: FootNoteNodeRenderer; customPopupRenderer?: FootNotePopupRenderer; interactive?: boolean; hidePopup?: boolean; + onPopupClick?: FootNotePopupClickHandler; } export class FootNoteNodeConfigProvider { @@ -27,7 +33,7 @@ export class FootNoteNodeConfigProvider { private _customPopupRenderer?: FootNotePopupRenderer; private _hidePopup: boolean; private _interactive: boolean; - + private _onPopupClick?: FootNotePopupClickHandler; get customNodeRenderer() { return this._customNodeRenderer; } @@ -36,6 +42,10 @@ export class FootNoteNodeConfigProvider { return this._customPopupRenderer; } + get onPopupClick() { + return this._onPopupClick; + } + get doc() { return this.std.store; } @@ -56,6 +66,7 @@ export class FootNoteNodeConfigProvider { this._customPopupRenderer = config.customPopupRenderer; this._hidePopup = config.hidePopup ?? false; this._interactive = config.interactive ?? true; + this._onPopupClick = config.onPopupClick; } setCustomNodeRenderer(renderer: FootNoteNodeRenderer) { @@ -73,6 +84,10 @@ export class FootNoteNodeConfigProvider { setInteractive(interactive: boolean) { this._interactive = interactive; } + + setPopupClick(onPopupClick: FootNotePopupClickHandler) { + this._onPopupClick = onPopupClick; + } } export const FootNoteNodeConfigIdentifier = diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts index 7cdab0f83..d9ae95c86 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts @@ -24,6 +24,9 @@ import { ref } from 'lit-html/directives/ref.js'; import { HoverController } from '../../../../../hover/controller'; import type { FootNoteNodeConfigProvider } from './footnote-config'; +// Virtual padding for the footnote popup overflow detection offsets. +const POPUP_SHIFT_PADDING = 8; + export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { static override styles = css` .footnote-node { @@ -64,6 +67,10 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { return this.config?.hidePopup; } + get onPopupClick() { + return this.config?.onPopupClick; + } + get inlineEditor() { const inlineRoot = this.closest>( `[${INLINE_ROOT_ATTR}]` @@ -92,6 +99,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { .footnote=${footnote} .std=${this.std} .abortController=${abortController} + .onPopupClick=${this.onPopupClick} >`; }; @@ -130,7 +138,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { referenceElement: this, placement: 'top', autoUpdate: true, - middleware: [shift()], + middleware: [shift({ padding: POPUP_SHIFT_PADDING })], }, }; }, diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts index 3ea015660..b9aa7bc00 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-popup.ts @@ -18,7 +18,8 @@ import { LightLoadingIcon, WebIcon16, } from '../../../../../icons'; -import { RefNodeSlotsProvider } from '../../../../extension/ref-node-slots'; +import { PeekViewProvider } from '../../../../../peek/service'; +import type { FootNotePopupClickHandler } from './footnote-config'; export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { static override styles = css` @@ -130,29 +131,49 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { /** * When clicking the chip, we will navigate to the reference doc or open the url */ - private readonly _onChipClick = () => { - const referenceType = this.footnote.reference.type; - const { docId, url } = this.footnote.reference; - switch (referenceType) { - case 'doc': { - if (!docId) { - break; + private readonly _handleDocReference = (docId: string) => { + this.std + .getOptional(PeekViewProvider) + ?.peek({ + docId, + }) + .catch(console.error); + }; + + private readonly _handleUrlReference = (url: string) => { + window.open(url, '_blank'); + }; + + private readonly _handleReference = () => { + const { type, docId, url } = this.footnote.reference; + + switch (type) { + case 'doc': + if (docId) { + this._handleDocReference(docId); } - this.std - .getOptional(RefNodeSlotsProvider) - ?.docLinkClicked.emit({ pageId: docId, host: this.std.host }); break; - } case 'url': - if (!url) { - break; + if (url) { + this._handleUrlReference(url); } - window.open(url, '_blank'); break; } + this.abortController.abort(); }; + private readonly _onChipClick = () => { + // If the onPopupClick is defined, use it + if (this.onPopupClick) { + this.onPopupClick(this.footnote, this.abortController); + return; + } + + // Otherwise, handle the reference by default + this._handleReference(); + }; + override connectedCallback() { super.connectedCallback(); if (this.footnote.reference.type === 'url' && this.footnote.reference.url) { @@ -195,4 +216,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { @property({ attribute: false }) accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor onPopupClick: FootNotePopupClickHandler | undefined = undefined; } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/chat-text.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/chat-text.ts index 83c8d9c18..96a110f30 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/chat-text.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/chat-text.ts @@ -2,12 +2,14 @@ import './action-wrapper'; import type { EditorHost } from '@blocksuite/affine/block-std'; import { ShadowlessElement } from '@blocksuite/affine/block-std'; +import type { SpecBuilder } from '@blocksuite/affine/blocks'; import { WithDisposable } from '@blocksuite/affine/global/utils'; import { html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { createTextRenderer } from '../../../_common'; import { renderImages } from '../components/images'; + export class ChatText extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor host!: EditorHost; @@ -21,14 +23,17 @@ export class ChatText extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor state: 'finished' | 'generating' = 'finished'; + @property({ attribute: false }) + accessor previewSpecBuilder!: SpecBuilder; + protected override render() { const { attachments, text, host } = this; return html`${attachments && attachments.length > 0 ? renderImages(attachments) - : nothing}${createTextRenderer(host, { customHeading: true })( - text, - this.state - )} `; + : nothing}${createTextRenderer(host, { + customHeading: true, + extensions: this.previewSpecBuilder.value, + })(text, this.state)} `; } } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts index 1bbef686f..329cbb44f 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts @@ -6,6 +6,7 @@ import { FeatureFlagService, isInsidePageEditor, PaymentRequiredError, + type SpecBuilder, UnauthorizedError, } from '@blocksuite/affine/blocks'; import { WithDisposable } from '@blocksuite/affine/global/utils'; @@ -129,6 +130,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor updateContext!: (context: Partial) => void; + @property({ attribute: false }) + accessor previewSpecBuilder!: SpecBuilder; + @query('.chat-panel-messages') accessor messagesContainer: HTMLDivElement | null = null; @@ -316,6 +320,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { .attachments=${item.attachments} .text=${item.content} .state=${state} + .previewSpecBuilder=${this.previewSpecBuilder} > ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} ${this.renderEditorActions(item, isLast)}`; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts index 48b139809..b4d95d8dd 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts @@ -3,7 +3,10 @@ import './chat-panel-messages'; import type { EditorHost } from '@blocksuite/affine/block-std'; import { ShadowlessElement } from '@blocksuite/affine/block-std'; -import { NotificationProvider } from '@blocksuite/affine/blocks'; +import { + NotificationProvider, + type SpecBuilder, +} from '@blocksuite/affine/blocks'; import { debounce, WithDisposable } from '@blocksuite/affine/global/utils'; import type { Store } from '@blocksuite/affine/store'; import { css, html, type PropertyValues } from 'lit'; @@ -172,6 +175,9 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor docDisplayConfig!: DocDisplayConfig; + @property({ attribute: false }) + accessor previewSpecBuilder!: SpecBuilder; + @state() accessor isLoading = false; @@ -315,6 +321,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { .updateContext=${this.updateContext} .host=${this.host} .isLoading=${this.isLoading} + .previewSpecBuilder=${this.previewSpecBuilder} > ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} ${shouldRenderCopyMore @@ -473,12 +479,16 @@ export class AIChatBlockPeekView extends LitElement { } = this; const { messages: currentChatMessages } = chatContext; + const textRendererOptions: TextRendererOptions = { + extensions: this.previewSpecBuilder.value, + }; return html`
@@ -511,6 +521,9 @@ export class AIChatBlockPeekView extends LitElement { @property({ attribute: false }) accessor host!: EditorHost; + @property({ attribute: false }) + accessor previewSpecBuilder!: SpecBuilder; + @state() accessor _historyMessages: ChatMessage[] = []; @@ -534,10 +547,12 @@ declare global { export const AIChatBlockPeekViewTemplate = ( parentModel: AIChatBlockModel, - host: EditorHost + host: EditorHost, + previewSpecBuilder: SpecBuilder ) => { return html``; }; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/preview.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/preview.ts index 90e08b144..20962bcad 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/preview.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/preview.ts @@ -1,7 +1,27 @@ import { AIChatBlockSpec } from '@affine/core/blocksuite/presets/blocks/ai-chat-block'; -import { SpecProvider } from '@blocksuite/affine/blocks'; +import { PeekViewService } from '@affine/core/modules/peek-view'; +import { AppThemeService } from '@affine/core/modules/theme'; +import { + type BlockStdScope, + LifeCycleWatcher, + StdIdentifier, +} from '@blocksuite/affine/block-std'; +import { + ColorScheme, + createSignalFromObservable, + type Signal, + type SpecBuilder, + SpecProvider, + type ThemeExtension, + ThemeExtensionIdentifier, +} from '@blocksuite/affine/blocks'; +import type { Container } from '@blocksuite/affine/global/di'; import type { ExtensionType } from '@blocksuite/affine/store'; +import type { FrameworkProvider } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; +import { buildDocDisplayMetaExtension } from './custom/root-block'; +import { patchPeekViewService } from './custom/spec-patchers'; import { getFontConfigExtension } from './font-extension'; const CustomSpecs: ExtensionType[] = [ @@ -18,3 +38,73 @@ export function effects() { // Patch edgeless preview spec for blocksuite surface-ref and embed-synced-doc patchPreviewSpec('edgeless:preview', CustomSpecs); } + +export function getPagePreviewThemeExtension(framework: FrameworkProvider) { + class AffinePagePreviewThemeExtension + extends LifeCycleWatcher + implements ThemeExtension + { + static override readonly key = 'affine-page-preview-theme'; + + readonly theme: Signal; + + readonly disposables: (() => void)[] = []; + + static override setup(di: Container) { + super.setup(di); + di.override(ThemeExtensionIdentifier, AffinePagePreviewThemeExtension, [ + StdIdentifier, + ]); + } + + constructor(std: BlockStdScope) { + super(std); + const theme$: Observable = framework + .get(AppThemeService) + .appTheme.theme$.map(theme => { + return theme === ColorScheme.Dark + ? ColorScheme.Dark + : ColorScheme.Light; + }); + const { signal, cleanup } = createSignalFromObservable( + theme$, + ColorScheme.Light + ); + this.theme = signal; + this.disposables.push(cleanup); + } + + getAppTheme() { + return this.theme; + } + + getEdgelessTheme() { + return this.theme; + } + + override unmounted() { + this.dispose(); + } + + dispose() { + this.disposables.forEach(dispose => dispose()); + } + } + + return AffinePagePreviewThemeExtension; +} + +export function createPageModePreviewSpecs( + framework: FrameworkProvider +): SpecBuilder { + const specProvider = SpecProvider.getInstance(); + const pagePreviewSpec = specProvider.getSpec('page:preview'); + // Enable theme extension, doc display meta extension and peek view service + const peekViewService = framework.get(PeekViewService); + pagePreviewSpec.extend([ + getPagePreviewThemeExtension(framework), + buildDocDisplayMetaExtension(framework), + patchPeekViewService(peekViewService), + ]); + return pagePreviewSpec; +} 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 0bf0f3754..01ad60bb2 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 @@ -1,4 +1,5 @@ import { ChatPanel } from '@affine/core/blocksuite/presets/ai'; +import { createPageModePreviewSpecs } from '@affine/core/components/blocksuite/block-suite-editor/specs/preview'; import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { DocSearchMenuService } from '@affine/core/modules/doc-search-menu/services'; @@ -83,6 +84,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( ); }, }; + chatPanelRef.current.previewSpecBuilder = + createPageModePreviewSpecs(framework); } else { chatPanelRef.current.host = editor.host; chatPanelRef.current.doc = editor.doc; diff --git a/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx b/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx new file mode 100644 index 000000000..2c428881e --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx @@ -0,0 +1,29 @@ +import { toReactNode } from '@affine/component'; +import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/presets/ai'; +import type { EditorHost } from '@blocksuite/affine/block-std'; +import { useFramework } from '@toeverything/infra'; +import { useMemo } from 'react'; + +import type { AIChatBlockModel } from '../../../../blocksuite/blocks/ai-chat-block/ai-chat-model'; +import { createPageModePreviewSpecs } from '../../../../components/blocksuite/block-suite-editor/specs/preview'; + +export type AIChatBlockPeekViewProps = { + model: AIChatBlockModel; + host: EditorHost; +}; + +export const AIChatBlockPeekView = ({ + model, + host, +}: AIChatBlockPeekViewProps) => { + const framework = useFramework(); + return useMemo(() => { + const previewSpecBuilder = createPageModePreviewSpecs(framework); + const template = AIChatBlockPeekViewTemplate( + model, + host, + previewSpecBuilder + ); + return toReactNode(template); + }, [framework, model, host]); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index cc1d56527..e30112c73 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -1,11 +1,11 @@ import { toReactNode } from '@affine/component'; -import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/presets/ai'; import { BlockComponent } from '@blocksuite/affine/block-std'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ActivePeekView } from '../entities/peek-view'; import { PeekViewService } from '../services/peek-view'; +import { AIChatBlockPeekView } from './ai-chat-block-peek-view'; import { AttachmentPreviewPeekView } from './attachment-preview'; import { DocPeekPreview } from './doc-preview'; import { ImagePreviewPeekView } from './image-preview'; @@ -46,8 +46,7 @@ function renderPeekView({ info }: ActivePeekView, animating?: boolean) { } if (info.type === 'ai-chat-block') { - const template = AIChatBlockPeekViewTemplate(info.model, info.host); - return toReactNode(template); + return ; } return null; // unreachable