From 29995e498ab325a156e788eb9dad49b5cb1a7c6d Mon Sep 17 00:00:00 2001 From: L-Sun Date: Tue, 21 Jan 2025 16:00:49 +0000 Subject: [PATCH] feat(editor): add start-with-ai button for empty doc (#9836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [BS-2391](https://linear.app/affine-design/issue/BS-2391/bs-ai-toolbar-空状态下添加-actions-列表) https://github.com/user-attachments/assets/cbded517-2d3d-4a75-b144-644e2b03f68a --- .../src/root-block/page/page-root-block.ts | 5 +- .../presets/ai/actions/doc-handler.ts | 68 +++++++++++++++++-- .../block-suite-editor/starter-bar.tsx | 31 ++++++++- .../affine-cloud-copilot/e2e/copilot.spec.ts | 9 +++ 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/blocksuite/blocks/src/root-block/page/page-root-block.ts b/blocksuite/blocks/src/root-block/page/page-root-block.ts index cdfdbcdbd4..42bac3f24f 100644 --- a/blocksuite/blocks/src/root-block/page/page-root-block.ts +++ b/blocksuite/blocks/src/root-block/page/page-root-block.ts @@ -117,14 +117,16 @@ export class PageRootBlockComponent extends BlockComponent< /** * Focus the first paragraph in the default note block. * If there is no paragraph, create one. + * @return { id: string, created: boolean } id of the focused paragraph and whether it is created or not */ - focusFirstParagraph = () => { + focusFirstParagraph = (): { id: string; created: boolean } => { const defaultNote = this._getDefaultNoteBlock(); const firstText = defaultNote?.children.find(block => matchFlavours(block, ['affine:paragraph', 'affine:list', 'affine:code']) ); if (firstText) { focusTextModel(this.std, firstText.id); + return { id: firstText.id, created: false }; } else { const newFirstParagraphId = this.doc.addBlock( 'affine:paragraph', @@ -133,6 +135,7 @@ export class PageRootBlockComponent extends BlockComponent< 0 ); focusTextModel(this.std, newFirstParagraphId); + return { id: newFirstParagraphId, created: true }; } }; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/doc-handler.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/doc-handler.ts index 060adf8131..260bcb399f 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/actions/doc-handler.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/doc-handler.ts @@ -1,11 +1,14 @@ import { type EditorHost, TextSelection } from '@blocksuite/affine/block-std'; -import type { - AffineAIPanelWidget, - AffineAIPanelWidgetConfig, - AIError, +import { + type AffineAIPanelWidget, + type AffineAIPanelWidgetConfig, + type AIError, + type AIItemGroupConfig, + createLitPortal, } from '@blocksuite/affine/blocks'; import { assertExists } from '@blocksuite/affine/global/utils'; -import type { TemplateResult } from 'lit'; +import { flip, offset } from '@floating-ui/dom'; +import { html, type TemplateResult } from 'lit'; import { buildCopyConfig, @@ -207,7 +210,10 @@ export function actionToHandler( }; } -export function handleInlineAskAIAction(host: EditorHost) { +export function handleInlineAskAIAction( + host: EditorHost, + actionGroups?: AIItemGroupConfig[] +) { const panel = getAIPanelWidget(host); const selection = host.selection.find(TextSelection); const lastBlockPath = selection @@ -216,6 +222,7 @@ export function handleInlineAskAIAction(host: EditorHost) { if (!lastBlockPath) return; const block = host.view.getBlock(lastBlockPath); if (!block) return; + const generateAnswer: AffineAIPanelWidgetConfig['generateAnswer'] = ({ finish, input, @@ -244,7 +251,54 @@ export function handleInlineAskAIAction(host: EditorHost) { }) .catch(console.error); }; - assertExists(panel.config); + if (!panel.config) return; + panel.config.generateAnswer = generateAnswer; + + if (!actionGroups) { + panel.toggle(block); + return; + } + + let actionPanel: HTMLDivElement | null = null; + let abortController: AbortController | null = null; + const clear = () => { + abortController?.abort(); + actionPanel = null; + abortController = null; + }; + + panel.config.inputCallback = text => { + if (!actionPanel) return; + actionPanel.style.visibility = text ? 'hidden' : 'visible'; + }; + panel.config.hideCallback = () => { + clear(); + }; + panel.toggle(block); + + setTimeout(() => { + abortController = new AbortController(); + actionPanel = createLitPortal({ + template: html` + { + panel.restoreSelection(); + clear(); + }} + > + `, + computePosition: { + referenceElement: panel, + placement: 'top-start', + middleware: [flip(), offset({ mainAxis: 3 })], + autoUpdate: true, + }, + abortController: abortController, + closeOnClickAway: true, + }); + }, 0); } diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx index a04da56380..dde81b7e52 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/starter-bar.tsx @@ -1,3 +1,5 @@ +import { handleInlineAskAIAction } from '@affine/core/blocksuite/presets/ai'; +import { pageAIGroups } from '@affine/core/blocksuite/presets/ai/_common/config'; import { DocsService } from '@affine/core/modules/doc'; import { EditorService } from '@affine/core/modules/editor'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; @@ -5,6 +7,7 @@ import { TemplateDocService } from '@affine/core/modules/template-doc'; import { TemplateListMenu } from '@affine/core/modules/template-doc/view/template-list-menu'; import { useI18n } from '@affine/i18n'; import track from '@affine/track'; +import { PageRootBlockComponent } from '@blocksuite/affine/blocks'; import type { Store } from '@blocksuite/affine/store'; import { AiIcon, @@ -62,6 +65,7 @@ const StarterBarNotEmpty = ({ doc }: { doc: Store }) => { [doc.id, templateDocService.list] ) ); + const enableAI = useLiveData(featureFlagService.flags.enable_ai.$); const enableTemplateDoc = useLiveData( featureFlagService.flags.enable_template_doc.$ ); @@ -85,10 +89,29 @@ const StarterBarNotEmpty = ({ doc }: { doc: Store }) => { setTemplateMenuOpen(open); }, []); - const showAI = false; + const startWithAI = useCallback(() => { + const std = editorService.editor.editorContainer$.value?.std; + if (!std) return; + + const rootBlockId = std.host.doc.root?.id; + if (!rootBlockId) return; + + const rootComponent = std.view.getBlock(rootBlockId); + if (!(rootComponent instanceof PageRootBlockComponent)) return; + + const { id, created } = rootComponent.focusFirstParagraph(); + if (created) { + std.view.viewUpdated.once(v => { + if (v.id === id) handleInlineAskAIAction(std.host, pageAIGroups); + }); + } else { + handleInlineAskAIAction(std.host, pageAIGroups); + } + }, [editorService.editor]); + const showTemplate = !isTemplate && enableTemplateDoc; - if (!showAI && !showTemplate) { + if (!enableAI && !showTemplate) { return null; } @@ -96,10 +119,12 @@ const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
{t['com.affine.page-starter-bar.start']()}
    - {showAI ? ( + {enableAI ? ( } text={t['com.affine.page-starter-bar.ai']()} + onClick={startWithAI} /> ) : null} diff --git a/tests/affine-cloud-copilot/e2e/copilot.spec.ts b/tests/affine-cloud-copilot/e2e/copilot.spec.ts index 55ee19c2bc..3e60b23e88 100644 --- a/tests/affine-cloud-copilot/e2e/copilot.spec.ts +++ b/tests/affine-cloud-copilot/e2e/copilot.spec.ts @@ -431,6 +431,15 @@ test.describe('chat panel', () => { expect(history[1].name).toBe('AFFiNE AI'); expect(await page.locator('chat-panel affine-link').count()).toBe(0); }); + + test('can trigger inline ai input and action panel by clicking Start with AI button', async ({ + page, + }) => { + await clickNewPageButton(page); + await page.getByTestId('start-with-ai-badge').click(); + await expect(page.locator('affine-ai-panel-widget')).toBeVisible(); + await expect(page.locator('ask-ai-panel')).toBeVisible(); + }); }); test.describe('chat with block', () => {