diff --git a/package.json b/package.json index cb5143de7f..d580e69d3e 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "string-width": "^7.1.0", "ts-node": "^10.9.2", "typescript": "^5.4.5", + "unplugin-swc": "^1.4.5", "vite": "^5.2.8", "vite-plugin-istanbul": "^6.0.0", "vite-plugin-static-copy": "^1.0.2", diff --git a/packages/common/env/package.json b/packages/common/env/package.json index 74efba3b75..de870055e2 100644 --- a/packages/common/env/package.json +++ b/packages/common/env/package.json @@ -27,4 +27,4 @@ "zod": "^3.22.4" }, "version": "0.14.0" -} +} \ No newline at end of file diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index b8cae96566..d4b78aca8b 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -68,4 +68,4 @@ } }, "version": "0.14.0" -} +} \ No newline at end of file diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index a9a35b5eb9..55051b8e53 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -109,4 +109,4 @@ "yjs": "^13.6.14" }, "version": "0.14.0" -} +} \ No newline at end of file diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index ab065af531..9f947efec3 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -33,10 +33,12 @@ "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@dotlottie/player-component": "^2.7.12", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.4", "@emotion/server": "^11.11.0", "@emotion/styled": "^11.11.5", + "@floating-ui/dom": "^1.6.5", "@juggle/resize-observer": "^3.4.0", "@marsidev/react-turnstile": "^0.7.0", "@radix-ui/react-collapsible": "^1.0.3", @@ -69,7 +71,7 @@ "jotai-devtools": "^0.10.0", "jotai-effect": "^1.0.0", "jotai-scope": "^0.6.0", - "lit": "^3.1.2", + "lit": "^3.1.3", "lodash-es": "^4.17.21", "lottie-react": "^2.4.0", "lottie-web": "^5.12.2", @@ -113,4 +115,4 @@ "mime-types": "^2.1.35", "vitest": "1.6.0" } -} +} \ No newline at end of file diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/ask-ai-button.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/ask-ai-button.ts new file mode 100644 index 0000000000..22f35a6444 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/ask-ai-button.ts @@ -0,0 +1,210 @@ +import './ask-ai-panel.js'; + +import { type EditorHost, WithDisposable } from '@blocksuite/block-std'; +import { + type AIItemGroupConfig, + AIStarIcon, + EdgelessRootService, +} from '@blocksuite/blocks'; +import { createLitPortal, HoverController } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { flip, offset } from '@floating-ui/dom'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getRootService } from '../../utils/selection-utils.js'; + +type buttonSize = 'small' | 'middle' | 'large'; +type toggleType = 'hover' | 'click'; + +const buttonWidthMap: Record = { + small: '72px', + middle: '76px', + large: '82px', +}; + +const buttonHeightMap: Record = { + small: '24px', + middle: '32px', + large: '32px', +}; + +export type AskAIButtonOptions = { + size: buttonSize; + backgroundColor?: string; + boxShadow?: string; + panelWidth?: number; +}; + +@customElement('ask-ai-button') +export class AskAIButton extends WithDisposable(LitElement) { + get _edgeless() { + const rootService = getRootService(this.host); + if (rootService instanceof EdgelessRootService) { + return rootService; + } + return null; + } + + static override styles = css` + .ask-ai-button { + border-radius: 4px; + position: relative; + } + + .ask-ai-icon-button { + display: flex; + align-items: center; + justify-content: center; + color: var(--affine-brand-color); + font-size: var(--affine-font-sm); + font-weight: 500; + } + + .ask-ai-icon-button.small { + font-size: var(--affine-font-xs); + svg { + scale: 0.8; + margin-right: 2px; + } + } + + .ask-ai-icon-button.large { + font-size: var(--affine-font-md); + svg { + scale: 1.2; + } + } + + .ask-ai-icon-button span { + line-height: 22px; + } + + .ask-ai-icon-button svg { + margin-right: 4px; + color: var(--affine-brand-color); + } + `; + + @query('.ask-ai-button') + private accessor _askAIButton!: HTMLDivElement; + + private _abortController: AbortController | null = null; + + private readonly _whenHover = new HoverController( + this, + ({ abortController }) => { + return { + template: html``, + computePosition: { + referenceElement: this, + placement: 'top-start', + middleware: [flip(), offset(-40)], + autoUpdate: true, + }, + }; + }, + { allowMultiple: true } + ); + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor actionGroups!: AIItemGroupConfig[]; + + @property({ attribute: false }) + accessor toggleType: toggleType = 'hover'; + + @property({ attribute: false }) + accessor options: AskAIButtonOptions = { + size: 'middle', + backgroundColor: undefined, + boxShadow: undefined, + panelWidth: 330, + }; + + private readonly _clearAbortController = () => { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + }; + + private readonly _toggleAIPanel = () => { + if (this.toggleType !== 'click') { + return; + } + + if (this._abortController) { + this._clearAbortController(); + return; + } + + this._abortController = new AbortController(); + assertExists(this._askAIButton); + const panelMinWidth = this.options.panelWidth || 330; + createLitPortal({ + template: html``, + container: this._askAIButton, + computePosition: { + referenceElement: this._askAIButton, + placement: 'bottom-start', + middleware: [flip(), offset(4)], + autoUpdate: true, + }, + abortController: this._abortController, + closeOnClickAway: true, + }); + }; + + override firstUpdated() { + this.disposables.add(() => { + this._edgeless?.tool.setEdgelessTool({ type: 'default' }); + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._clearAbortController(); + } + + override render() { + const { size = 'small', backgroundColor, boxShadow } = this.options; + const { toggleType } = this; + const buttonStyles = styleMap({ + backgroundColor: backgroundColor || 'transparent', + boxShadow: boxShadow || 'none', + }); + return html`
+ + ${AIStarIcon} Ask AI +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ask-ai-button': AskAIButton; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/ask-ai-panel.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/ask-ai-panel.ts new file mode 100644 index 0000000000..17421c8e4e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/ask-ai-panel.ts @@ -0,0 +1,100 @@ +import { type EditorHost, WithDisposable } from '@blocksuite/block-std'; +import { + type AIItemGroupConfig, + EdgelessRootService, +} from '@blocksuite/blocks'; +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getRootService } from '../../utils/selection-utils.js'; + +@customElement('ask-ai-panel') +export class AskAIPanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + position: absolute; + } + + .ask-ai-panel { + box-sizing: border-box; + padding: 8px; + max-height: 374px; + overflow-y: auto; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border-radius: 8px; + z-index: var(--affine-z-index-popover); + } + + .ask-ai-panel::-webkit-scrollbar { + width: 5px; + max-height: 100px; + } + .ask-ai-panel::-webkit-scrollbar-thumb { + border-radius: 20px; + } + .ask-ai-panel:hover::-webkit-scrollbar-thumb { + background-color: var(--affine-black-30); + } + .ask-ai-panel::-webkit-scrollbar-corner { + display: none; + } + `; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor actionGroups!: AIItemGroupConfig[]; + + @property({ attribute: false }) + accessor abortController: AbortController | null = null; + + @property({ attribute: false }) + accessor minWidth = 330; + + get _edgeless() { + const rootService = getRootService(this.host); + if (rootService instanceof EdgelessRootService) { + return rootService; + } + return null; + } + + get _actionGroups() { + const filteredConfig = this.actionGroups + .map(group => ({ + ...group, + items: group.items.filter(item => + item.showWhen + ? item.showWhen( + this.host.command.chain(), + this._edgeless ? 'edgeless' : 'page', + this.host + ) + : true + ), + })) + .filter(group => group.items.length > 0); + return filteredConfig; + } + + override render() { + const style = styleMap({ + minWidth: `${this.minWidth}px`, + }); + return html`
+ +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ask-ai-panel': AskAIPanel; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/config.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/config.ts new file mode 100644 index 0000000000..6bcc85c55c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/config.ts @@ -0,0 +1,521 @@ +import type { Chain, EditorHost, InitCommandCtx } from '@blocksuite/block-std'; +import { + type AIItemGroupConfig, + type AISubItemConfig, + type CopilotSelectionController, + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + type EdgelessElementToolbarWidget, + matchFlavours, +} from '@blocksuite/blocks'; +import type { TemplateResult } from 'lit'; + +import { actionToHandler } from '../actions/doc-handler.js'; +import { actionToHandler as edgelessActionToHandler } from '../actions/edgeless-handler.js'; +import { + imageFilterStyles, + imageProcessingTypes, + textTones, + translateLangs, +} from '../actions/types.js'; +import { getAIPanel } from '../ai-panel.js'; +import { AIProvider } from '../provider.js'; +import { + getSelectedImagesAsBlobs, + getSelectedTextContent, + getSelections, +} from '../utils/selection-utils.js'; +import { + AIDoneIcon, + AIImageIcon, + AIImageIconWithAnimation, + AIMindMapIcon, + AIPenIcon, + AIPenIconWithAnimation, + AIPresentationIcon, + AIPresentationIconWithAnimation, + AISearchIcon, + AIStarIconWithAnimation, + ChatWithAIIcon, + ExplainIcon, + ImproveWritingIcon, + LanguageIcon, + LongerIcon, + MakeItRealIcon, + MakeItRealIconWithAnimation, + SelectionIcon, + ShorterIcon, + ToneIcon, +} from './icons.js'; + +export const translateSubItem: AISubItemConfig[] = translateLangs.map(lang => { + return { + type: lang, + handler: actionToHandler('translate', AIStarIconWithAnimation, { lang }), + }; +}); + +export const toneSubItem: AISubItemConfig[] = textTones.map(tone => { + return { + type: tone, + handler: actionToHandler('changeTone', AIStarIconWithAnimation, { tone }), + }; +}); + +export function createImageFilterSubItem( + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return imageFilterStyles.map(style => { + return { + type: style, + handler: edgelessHandler( + 'filterImage', + AIImageIconWithAnimation, + { + style, + }, + trackerOptions + ), + }; + }); +} + +export function createImageProcessingSubItem( + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return imageProcessingTypes.map(type => { + return { + type, + handler: edgelessHandler( + 'processImage', + AIImageIconWithAnimation, + { + type, + }, + trackerOptions + ), + }; + }); +} + +const blockActionTrackerOptions: BlockSuitePresets.TrackerOptions = { + control: 'block-action-bar', + where: 'ai-panel', +}; + +const textBlockShowWhen = (chain: Chain) => { + const [_, ctx] = chain + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length === 0) return false; + + return selectedModels.some(model => + matchFlavours(model, ['affine:paragraph', 'affine:list']) + ); +}; + +const codeBlockShowWhen = (chain: Chain) => { + const [_, ctx] = chain + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length > 1) return false; + + const model = selectedModels[0]; + return matchFlavours(model, ['affine:code']); +}; + +const imageBlockShowWhen = (chain: Chain) => { + const [_, ctx] = chain + .getSelectedModels({ + types: ['block'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length > 1) return false; + + const model = selectedModels[0]; + return matchFlavours(model, ['affine:image']); +}; + +const EditAIGroup: AIItemGroupConfig = { + name: 'edit with ai', + items: [ + { + name: 'Translate to', + icon: LanguageIcon, + showWhen: textBlockShowWhen, + subItem: translateSubItem, + }, + { + name: 'Change tone to', + icon: ToneIcon, + showWhen: textBlockShowWhen, + subItem: toneSubItem, + }, + { + name: 'Improve writing', + icon: ImproveWritingIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('improveWriting', AIStarIconWithAnimation), + }, + { + name: 'Make it longer', + icon: LongerIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('makeLonger', AIStarIconWithAnimation), + }, + { + name: 'Make it shorter', + icon: ShorterIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('makeShorter', AIStarIconWithAnimation), + }, + { + name: 'Continue writing', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('continueWriting', AIPenIconWithAnimation), + }, + ], +}; + +const DraftAIGroup: AIItemGroupConfig = { + name: 'draft with ai', + items: [ + { + name: 'Write an article about this', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('writeArticle', AIPenIconWithAnimation), + }, + { + name: 'Write a tweet about this', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('writeTwitterPost', AIPenIconWithAnimation), + }, + { + name: 'Write a poem about this', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('writePoem', AIPenIconWithAnimation), + }, + { + name: 'Write a blog post about this', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('writeBlogPost', AIPenIconWithAnimation), + }, + { + name: 'Brainstorm ideas about this', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('brainstorm', AIPenIconWithAnimation), + }, + ], +}; + +// actions that initiated from a note in edgeless mode +// 1. when running in doc mode, call requestRunInEdgeless (let affine to show toast) +// 2. when running in edgeless mode +// a. get selected in the note and let the edgeless action to handle it +// b. insert the result using the note shape +function edgelessHandler( + id: T, + generatingIcon: TemplateResult<1>, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return (host: EditorHost) => { + if (host.doc.root?.id === undefined) return; + const edgeless = ( + host.view.getWidget( + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + host.doc.root.id + ) as EdgelessElementToolbarWidget + )?.edgeless; + + if (!edgeless) { + AIProvider.slots.requestRunInEdgeless.emit({ host }); + } else { + edgeless.tools.setEdgelessTool({ type: 'copilot' }); + const currentController = edgeless.tools.controllers[ + 'copilot' + ] as CopilotSelectionController; + const selectedElements = edgeless.service.selection.selectedElements; + currentController.updateDragPointsWith(selectedElements, 10); + currentController.draggingAreaUpdated.emit(false); // do not show edgeless panel + + return edgelessActionToHandler( + id, + generatingIcon, + variants, + async () => { + const selections = getSelections(host); + const [markdown, attachments] = await Promise.all([ + getSelectedTextContent(host), + getSelectedImagesAsBlobs(host), + ]); + // for now if there are more than one selected blocks, we will not omit the attachments + const sendAttachments = + selections?.selectedBlocks?.length === 1 && attachments.length > 0; + return { + attachments: sendAttachments ? attachments : undefined, + content: sendAttachments ? '' : markdown, + }; + }, + trackerOptions + )(host); + } + }; +} + +const ReviewWIthAIGroup: AIItemGroupConfig = { + name: 'review with ai', + items: [ + { + name: 'Fix spelling', + icon: AIDoneIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('fixSpelling', AIStarIconWithAnimation), + }, + { + name: 'Fix grammar', + icon: AIDoneIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('improveGrammar', AIStarIconWithAnimation), + }, + { + name: 'Explain this image', + icon: AIPenIcon, + showWhen: imageBlockShowWhen, + handler: actionToHandler('explainImage', AIStarIconWithAnimation), + }, + { + name: 'Explain this code', + icon: ExplainIcon, + showWhen: codeBlockShowWhen, + handler: actionToHandler('explainCode', AIStarIconWithAnimation), + }, + { + name: 'Check code error', + icon: ExplainIcon, + showWhen: codeBlockShowWhen, + handler: actionToHandler('checkCodeErrors', AIStarIconWithAnimation), + }, + { + name: 'Explain selection', + icon: SelectionIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('explain', AIStarIconWithAnimation), + }, + ], +}; + +const GenerateWithAIGroup: AIItemGroupConfig = { + name: 'generate with ai', + items: [ + { + name: 'Summarize', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('summary', AIPenIconWithAnimation), + }, + { + name: 'Generate headings', + icon: AIPenIcon, + beta: true, + handler: actionToHandler('createHeadings', AIPenIconWithAnimation), + showWhen: chain => { + const [_, ctx] = chain + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length === 0) return false; + + return selectedModels.every( + model => + matchFlavours(model, ['affine:paragraph', 'affine:list']) && + !model.type.startsWith('h') + ); + }, + }, + { + name: 'Generate an image', + icon: AIImageIcon, + showWhen: textBlockShowWhen, + handler: edgelessHandler('createImage', AIImageIconWithAnimation), + }, + { + name: 'Generate outline', + icon: AIPenIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('writeOutline', AIPenIconWithAnimation), + }, + { + name: 'Brainstorm ideas with mind map', + icon: AIMindMapIcon, + showWhen: textBlockShowWhen, + handler: edgelessHandler('brainstormMindmap', AIPenIconWithAnimation), + }, + { + name: 'Generate presentation', + icon: AIPresentationIcon, + showWhen: textBlockShowWhen, + handler: edgelessHandler('createSlides', AIPresentationIconWithAnimation), + beta: true, + }, + { + name: 'Make it real', + icon: MakeItRealIcon, + beta: true, + showWhen: textBlockShowWhen, + handler: edgelessHandler('makeItReal', MakeItRealIconWithAnimation), + }, + { + name: 'Find actions', + icon: AISearchIcon, + showWhen: textBlockShowWhen, + handler: actionToHandler('findActions', AIStarIconWithAnimation), + beta: true, + }, + ], +}; + +const OthersAIGroup: AIItemGroupConfig = { + name: 'Others', + items: [ + { + name: 'Open AI Chat', + icon: ChatWithAIIcon, + handler: host => { + const panel = getAIPanel(host); + AIProvider.slots.requestContinueInChat.emit({ + host: host, + show: true, + }); + panel.hide(); + }, + }, + ], +}; + +export const AIItemGroups: AIItemGroupConfig[] = [ + ReviewWIthAIGroup, + EditAIGroup, + GenerateWithAIGroup, + DraftAIGroup, + OthersAIGroup, +]; + +export function buildAIImageItemGroups(): AIItemGroupConfig[] { + return [ + { + name: 'edit with ai', + items: [ + { + name: 'Explain this image', + icon: ExplainIcon, + showWhen: () => true, + handler: actionToHandler( + 'explainImage', + AIStarIconWithAnimation, + undefined, + blockActionTrackerOptions + ), + }, + ], + }, + { + name: 'generate with ai', + items: [ + { + name: 'Generate an image', + icon: AIImageIcon, + showWhen: () => true, + handler: edgelessHandler( + 'createImage', + AIImageIconWithAnimation, + undefined, + blockActionTrackerOptions + ), + }, + { + name: 'AI image filter', + icon: ImproveWritingIcon, + showWhen: (_, __, host) => + !!host.doc.awarenessStore.getFlag('enable_new_image_actions'), + subItem: createImageFilterSubItem(blockActionTrackerOptions), + subItemOffset: [12, -4], + beta: true, + }, + { + name: 'Image processing', + icon: AIImageIcon, + showWhen: (_, __, host) => + !!host.doc.awarenessStore.getFlag('enable_new_image_actions'), + subItem: createImageProcessingSubItem(blockActionTrackerOptions), + subItemOffset: [12, -6], + beta: true, + }, + { + name: 'Generate a caption', + icon: AIPenIcon, + showWhen: (_, __, host) => + !!host.doc.awarenessStore.getFlag('enable_new_image_actions'), + beta: true, + handler: actionToHandler( + 'generateCaption', + AIStarIconWithAnimation, + undefined, + blockActionTrackerOptions + ), + }, + ], + }, + OthersAIGroup, + ]; +} + +export function buildAICodeItemGroups(): AIItemGroupConfig[] { + return [ + { + name: 'edit with ai', + items: [ + { + name: 'Explain this code', + icon: ExplainIcon, + showWhen: () => true, + handler: actionToHandler( + 'explainCode', + AIStarIconWithAnimation, + undefined, + blockActionTrackerOptions + ), + }, + { + name: 'Check code error', + icon: ExplainIcon, + showWhen: () => true, + handler: actionToHandler( + 'checkCodeErrors', + AIStarIconWithAnimation, + undefined, + blockActionTrackerOptions + ), + }, + ], + }, + OthersAIGroup, + ]; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts new file mode 100644 index 0000000000..bf0029bd59 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts @@ -0,0 +1,1063 @@ +import '@dotlottie/player-component'; + +import { html } from 'lit'; + +export const AIStarIcon = html` + + + + + + + + + +`; + +export const AIStarIconWithAnimation = html``; + +export const SmallHintIcon = html` + + `; + +export const AIHelpIcon = html` + + + + + + + + + `; + +export const AffineIcon = (color: string) => + html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const ChatClearIcon = html` + + + + + + + + + `; + +export const ChatSendIcon = html` + + + + + + + + + + `; + +export const ChatAbortIcon = html` + + + + + + + + + `; + +export const CurrentSelectionIcon = html` + + + + + + + + + `; + +export const ImageIcon = html` + + `; + +export const SmallImageIcon = html` + + + + + + + + + `; + +export const DocIcon = html` + + + + + + + + + `; + +export const DownArrowIcon = html` + + `; + +export const CloseIcon = html` + + `; + +export const ReplaceIcon = html` + + `; + +export const InsertBelowIcon = html` + + `; + +export const InsertTopIcon = html` + +`; + +export const NewBlockIcon = html` + + + + + + + + + `; + +export const CreateIcon = html` + + + + + + + + + `; + +export const AffineAvatarIcon = html` + + + `; + +export const ActionIcon = html` + + `; + +export const LanguageIcon = html` + + `; + +export const ImproveWritingIcon = html` + + `; + +export const AIDoneIcon = html` + + `; + +export const ShorterIcon = html` + + `; + +export const LongerIcon = html` + + `; + +export const ExplainIcon = html` + + `; + +export const AIExplainIcon = html` + + + + + + + + + `; + +export const SelectionIcon = html` + +`; + +export const AIExplainSelectionIcon = html` + + + + + + + + + `; + +export const ToneIcon = html` + + `; + +export const AIChangeToneIcon = html` + + + + + + + + + `; + +export const AISearchIcon = html` + + `; + +export const AIImproveWritingIcon = html` + + + + + + + + + `; + +export const AIMakeLongerIcon = html` + + + + + + + + + `; + +export const AIMakeShorterIcon = html` + + + + + + + + + `; + +export const AIMakeRealIcon = html` + + + + + + + + + `; + +export const AIPenIcon = html` + + `; + +export const AIPenIconWithAnimation = html``; + +export const AIPresentationIcon = html` + + + + + + + + + `; + +export const AIPresentationIconWithAnimation = html``; + +export const AIMindMapIcon = html` + + + + + + + + + `; + +export const AIMindMapIconWithAnimation = html``; + +export const AIExpandMindMapIcon = html` + + + + + + + + + `; + +export const AIFindActionsIcon = html` + + `; + +export const AIImageIcon = html` + + + + + + + + + `; + +export const AIImageIconWithAnimation = html``; + +export const ChatWithAIIcon = html` + + `; + +export const MakeItRealIcon = html` + + `; + +export const MakeItRealIconWithAnimation = html``; + +export const ArrowDownIcon = html` + + `; + +export const ArrowUpIcon = html` + + `; + +export const DiscardIcon = html` + +`; + +export const ErrorTipIcon = html` + +`; + +export const CopyIcon = html` + + + + + + + + + `; + +export const RetryIcon = html` + + `; + +export const MoreIcon = html` + + + + + + + + + `; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/markdown-utils.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/markdown-utils.ts new file mode 100644 index 0000000000..efd69d4b23 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/markdown-utils.ts @@ -0,0 +1,70 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { MarkdownAdapter, titleMiddleware } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { type BlockModel, Job, type Slice } from '@blocksuite/store'; + +export async function getMarkdownFromSlice(host: EditorHost, slice: Slice) { + const job = new Job({ + collection: host.std.doc.collection, + middlewares: [titleMiddleware], + }); + const markdownAdapter = new MarkdownAdapter(job); + const markdown = await markdownAdapter.fromSlice(slice); + + return markdown.file; +} +export const markdownToSnapshot = async ( + markdown: string, + host: EditorHost +) => { + const job = new Job({ collection: host.std.doc.collection }); + const markdownAdapter = new MarkdownAdapter(job); + const { blockVersions, workspaceVersion, pageVersion } = + host.std.doc.collection.meta; + if (!blockVersions || !workspaceVersion || !pageVersion) + throw new Error( + 'Need blockVersions, workspaceVersion, pageVersion meta information to get slice' + ); + + const payload = { + file: markdown, + assets: job.assetsManager, + blockVersions, + pageVersion, + workspaceVersion, + workspaceId: host.std.doc.collection.id, + pageId: host.std.doc.id, + }; + + const snapshot = await markdownAdapter.toSliceSnapshot(payload); + assertExists(snapshot, 'import markdown failed, expected to get a snapshot'); + + return { + snapshot, + job, + }; +}; +export async function insertFromMarkdown( + host: EditorHost, + markdown: string, + parent?: string, + index?: number +) { + const { snapshot, job } = await markdownToSnapshot(markdown, host); + + const snapshots = snapshot.content[0].children; + + const models: BlockModel[] = []; + for (let i = 0; i < snapshots.length; i++) { + const blockSnapshot = snapshots[i]; + const model = await job.snapshotToBlock( + blockSnapshot, + host.std.doc, + parent, + (index ?? 0) + i + ); + models.push(model); + } + + return models; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/selection-utils.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/selection-utils.ts new file mode 100644 index 0000000000..66eddaf539 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/selection-utils.ts @@ -0,0 +1,117 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { + BlocksUtils, + EdgelessRootService, + type FrameBlockModel, + type ImageBlockModel, + type SurfaceBlockComponent, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { Slice } from '@blocksuite/store'; + +import { getMarkdownFromSlice } from './markdown-utils.js'; + +export const getRootService = (host: EditorHost) => { + return host.std.spec.getService('affine:page'); +}; + +export function getEdgelessRootFromEditor(editor: EditorHost) { + const edgelessRoot = editor.getElementsByTagName('affine-edgeless-root')[0]; + if (!edgelessRoot) { + alert('Please switch to edgeless mode'); + throw new Error('Please open switch to edgeless mode'); + } + return edgelessRoot; +} +export function getEdgelessService(editor: EditorHost) { + const rootService = editor.std.spec.getService('affine:page'); + if (rootService instanceof EdgelessRootService) { + return rootService; + } + alert('Please switch to edgeless mode'); + throw new Error('Please open switch to edgeless mode'); +} + +export async function selectedToCanvas(editor: EditorHost) { + const edgelessRoot = getEdgelessRootFromEditor(editor); + const { notes, frames, shapes, images } = BlocksUtils.splitElements( + edgelessRoot.service.selection.selectedElements + ); + if (notes.length + frames.length + images.length + shapes.length === 0) { + return; + } + const canvas = await edgelessRoot.clipboardController.toCanvas( + [...notes, ...frames, ...images], + shapes + ); + if (!canvas) { + return; + } + return canvas; +} + +export async function frameToCanvas( + frame: FrameBlockModel, + editor: EditorHost +) { + const edgelessRoot = getEdgelessRootFromEditor(editor); + const { notes, frames, shapes, images } = BlocksUtils.splitElements( + edgelessRoot.service.frame.getElementsInFrame(frame, true) + ); + if (notes.length + frames.length + images.length + shapes.length === 0) { + return; + } + const canvas = await edgelessRoot.clipboardController.toCanvas( + [...notes, ...frames, ...images], + shapes + ); + if (!canvas) { + return; + } + return canvas; +} + +export async function selectedToPng(editor: EditorHost) { + return (await selectedToCanvas(editor))?.toDataURL('image/png'); +} + +export async function getSelectedTextContent(editorHost: EditorHost) { + const slice = Slice.fromModels( + editorHost.std.doc, + getRootService(editorHost).selectedModels + ); + return getMarkdownFromSlice(editorHost, slice); +} + +export const stopPropagation = (e: Event) => { + e.stopPropagation(); +}; + +export function getSurfaceElementFromEditor(editor: EditorHost) { + const { doc } = editor; + const surfaceModel = doc.getBlockByFlavour('affine:surface')[0]; + assertExists(surfaceModel); + + const surfaceId = surfaceModel.id; + const surfaceElement = editor.querySelector( + `affine-surface[data-block-id="${surfaceId}"]` + ) as SurfaceBlockComponent; + assertExists(surfaceElement); + + return surfaceElement; +} + +export const getFirstImageInFrame = ( + frame: FrameBlockModel, + editor: EditorHost +) => { + const edgelessRoot = getEdgelessRootFromEditor(editor); + const elements = edgelessRoot.service.frame.getElementsInFrame(frame, false); + const image = elements.find(ele => { + if (!BlocksUtils.isCanvasElement(ele)) { + return ele.flavour === 'affine:image'; + } + return false; + }) as ImageBlockModel | undefined; + return image?.id; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/consts.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/consts.ts new file mode 100644 index 0000000000..f69ca9e0cc --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/consts.ts @@ -0,0 +1,29 @@ +export const EXCLUDING_COPY_ACTIONS = [ + 'brainstormMindmap', + 'expandMindmap', + 'makeItReal', + 'createSlides', + 'createImage', + 'findActions', + 'filterImage', + 'processImage', +]; + +export const EXCLUDING_INSERT_ACTIONS = ['generateCaption']; + +export const IMAGE_ACTIONS = ['createImage', 'processImage', 'filterImage']; + +const commonImageStages = ['Generating image', 'Rendering image']; + +export const generatingStages: { + [key in keyof Partial]: string[]; +} = { + makeItReal: ['Coding for you', 'Rendering the code'], + brainstormMindmap: ['Thinking about this topic', 'Rendering mindmap'], + createSlides: ['Thinking about this topic', 'Rendering slides'], + createImage: commonImageStages, + processImage: commonImageStages, + filterImage: commonImageStages, +}; + +export const INSERT_ABOVE_ACTIONS = ['createHeadings']; 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 new file mode 100644 index 0000000000..215dd3648e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/doc-handler.ts @@ -0,0 +1,247 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { + AffineAIPanelWidget, + AffineAIPanelWidgetConfig, + AIError, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; + +import { + buildCopyConfig, + buildErrorConfig, + buildFinishConfig, + buildGeneratingConfig, + getAIPanel, +} from '../ai-panel.js'; +import { createTextRenderer } from '../messages/text.js'; +import { AIProvider } from '../provider.js'; +import { reportResponse } from '../utils/action-reporter.js'; +import { + getSelectedImagesAsBlobs, + getSelectedTextContent, + getSelections, + selectAboveBlocks, +} from '../utils/selection-utils.js'; + +export function bindTextStream( + stream: BlockSuitePresets.TextStream, + { + update, + finish, + signal, + }: { + update: (text: string) => void; + finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void; + signal?: AbortSignal; + } +) { + (async () => { + let answer = ''; + signal?.addEventListener('abort', () => { + finish('aborted'); + reportResponse('aborted:stop'); + }); + for await (const data of stream) { + if (signal?.aborted) { + return; + } + answer += data; + update(answer); + } + finish('success'); + })().catch(err => { + if (signal?.aborted) return; + if (err.name === 'AbortError') { + finish('aborted'); + } else { + finish('error', err); + } + }); +} + +export function actionToStream( + id: T, + signal?: AbortSignal, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + const action = AIProvider.actions[id]; + if (!action || typeof action !== 'function') return; + return (host: EditorHost): BlockSuitePresets.TextStream => { + let stream: BlockSuitePresets.TextStream | undefined; + return { + async *[Symbol.asyncIterator]() { + const { currentTextSelection, selectedBlocks } = getSelections(host); + + let markdown: string; + let attachments: File[] = []; + + if (currentTextSelection?.isCollapsed()) { + markdown = await selectAboveBlocks(host); + } else { + [markdown, attachments] = await Promise.all([ + getSelectedTextContent(host), + getSelectedImagesAsBlobs(host), + ]); + } + + // for now if there are more than one selected blocks, we will not omit the attachments + const sendAttachments = + selectedBlocks?.length === 1 && attachments.length > 0; + const models = selectedBlocks?.map(block => block.model); + const control = trackerOptions?.control ?? 'format-bar'; + const where = trackerOptions?.where ?? 'ai-panel'; + const options = { + ...variants, + attachments: sendAttachments ? attachments : undefined, + input: sendAttachments ? '' : markdown, + stream: true, + host, + models, + signal, + control, + where, + docId: host.doc.id, + workspaceId: host.doc.collection.id, + } as Parameters[0]; + // @ts-expect-error todo: maybe fix this + stream = action(options); + if (!stream) return; + yield* stream; + }, + }; + }; +} + +export function actionToGenerateAnswer< + T extends keyof BlockSuitePresets.AIActions, +>( + id: T, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return (host: EditorHost) => { + return ({ + signal, + update, + finish, + }: { + input: string; + signal?: AbortSignal; + update: (text: string) => void; + finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void; + }) => { + const { selectedBlocks: blocks } = getSelections(host); + if (!blocks || blocks.length === 0) return; + const stream = actionToStream( + id, + signal, + variants, + trackerOptions + )?.(host); + if (!stream) return; + bindTextStream(stream, { update, finish, signal }); + }; + }; +} + +/** + * TODO: Should update config according to the action type + * When support mind-map. generate image, generate slides on doc mode or in edgeless note block + * Currently, only support text action + */ +function updateAIPanelConfig( + aiPanel: AffineAIPanelWidget, + id: T, + generatingIcon: TemplateResult<1>, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + const { config, host } = aiPanel; + assertExists(config); + config.generateAnswer = actionToGenerateAnswer( + id, + variants, + trackerOptions + )(host); + config.answerRenderer = createTextRenderer(host, { maxHeight: 320 }); + config.finishStateConfig = buildFinishConfig(aiPanel, id); + config.generatingStateConfig = buildGeneratingConfig(generatingIcon); + config.errorStateConfig = buildErrorConfig(aiPanel); + config.copy = buildCopyConfig(aiPanel); + config.discardCallback = () => { + reportResponse('result:discard'); + }; +} + +export function actionToHandler( + id: T, + generatingIcon: TemplateResult<1>, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return (host: EditorHost) => { + const aiPanel = getAIPanel(host); + updateAIPanelConfig(aiPanel, id, generatingIcon, variants, trackerOptions); + const { selectedBlocks: blocks } = getSelections(aiPanel.host); + if (!blocks || blocks.length === 0) return; + const block = blocks.at(-1); + assertExists(block); + aiPanel.toggle(block, 'placeholder'); + }; +} + +export function handleInlineAskAIAction(host: EditorHost) { + const panel = getAIPanel(host); + const selection = host.selection.find('text'); + const lastBlockPath = selection + ? selection.to?.blockId ?? selection.blockId + : null; + if (!lastBlockPath) return; + const block = host.view.getBlock(lastBlockPath); + if (!block) return; + const generateAnswer: AffineAIPanelWidgetConfig['generateAnswer'] = ({ + finish, + input, + signal, + update, + }) => { + if (!AIProvider.actions.chat) return; + + // recover selection to get content from above blocks + assertExists(selection); + host.selection.set([selection]); + + selectAboveBlocks(host) + .then(context => { + assertExists(AIProvider.actions.chat); + const stream = AIProvider.actions.chat({ + input: `${context}\n${input}`, + stream: true, + host, + where: 'inline-chat-panel', + control: 'chat-send', + docId: host.doc.id, + workspaceId: host.doc.collection.id, + }); + bindTextStream(stream, { update, finish, signal }); + }) + .catch(console.error); + }; + assertExists(panel.config); + panel.config.generateAnswer = generateAnswer; + panel.toggle(block); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-handler.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-handler.ts new file mode 100644 index 0000000000..05ead0d531 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-handler.ts @@ -0,0 +1,544 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { + AffineAIPanelWidget, + AIError, + EdgelessCopilotWidget, + MindmapElementModel, +} from '@blocksuite/blocks'; +import { + BlocksUtils, + EdgelessTextBlockModel, + ImageBlockModel, + NoteBlockModel, + ShapeElementModel, + TextElementModel, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { Slice } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +import { getAIPanel } from '../ai-panel.js'; +import { + createMindmapExecuteRenderer, + createMindmapRenderer, +} from '../messages/mindmap.js'; +import { createSlidesRenderer } from '../messages/slides-renderer.js'; +import { createTextRenderer } from '../messages/text.js'; +import { + createIframeRenderer, + createImageRenderer, +} from '../messages/wrapper.js'; +import { AIProvider } from '../provider.js'; +import { reportResponse } from '../utils/action-reporter.js'; +import { + getEdgelessCopilotWidget, + isMindmapChild, + isMindMapRoot, +} from '../utils/edgeless.js'; +import { copyTextAnswer } from '../utils/editor-actions.js'; +import { getContentFromSlice } from '../utils/markdown-utils.js'; +import { + getCopilotSelectedElems, + getSelectedNoteAnchor, + getSelections, +} from '../utils/selection-utils.js'; +import { EXCLUDING_COPY_ACTIONS, IMAGE_ACTIONS } from './consts.js'; +import { bindTextStream } from './doc-handler.js'; +import { + actionToErrorResponse, + actionToGenerating, + actionToResponse, + getElementToolbar, + responses, +} from './edgeless-response.js'; +import type { CtxRecord } from './types.js'; + +type AnswerRenderer = NonNullable< + AffineAIPanelWidget['config'] +>['answerRenderer']; + +function actionToRenderer( + id: T, + host: EditorHost, + ctx: CtxRecord +): AnswerRenderer { + if (id === 'brainstormMindmap') { + const selectedElements = ctx.get()[ + 'selectedElements' + ] as BlockSuite.EdgelessModelType[]; + + if ( + isMindMapRoot(selectedElements[0] || isMindmapChild(selectedElements[0])) + ) { + const mindmap = selectedElements[0].group as MindmapElementModel; + + return createMindmapRenderer(host, ctx, mindmap.style); + } + + return createMindmapRenderer(host, ctx); + } + + if (id === 'expandMindmap') { + return createMindmapExecuteRenderer(host, ctx, ctx => { + responses['expandMindmap']?.(host, ctx); + }); + } + + if (id === 'createSlides') { + return createSlidesRenderer(host, ctx); + } + + if (id === 'makeItReal') { + return createIframeRenderer(host, { height: 300 }); + } + + if (IMAGE_ACTIONS.includes(id)) { + return createImageRenderer(host, { height: 300 }); + } + + return createTextRenderer(host, { maxHeight: 320 }); +} + +async function getContentFromHubBlockModel( + host: EditorHost, + models: EdgelessTextBlockModel[] | NoteBlockModel[] +) { + return ( + await Promise.all( + models.map(model => { + const slice = Slice.fromModels(host.doc, model.children); + return getContentFromSlice(host, slice); + }) + ) + ) + .map(content => content.trim()) + .filter(content => content.length); +} + +export async function getContentFromSelected( + host: EditorHost, + selected: BlockSuite.EdgelessModelType[] +) { + type RemoveUndefinedKey = T & { + [P in K]-?: Exclude; + }; + + function isShapeWithText( + el: ShapeElementModel + ): el is RemoveUndefinedKey { + return el.text !== undefined && el.text.length !== 0; + } + + function isImageWithCaption( + el: ImageBlockModel + ): el is RemoveUndefinedKey { + return el.caption !== undefined && el.caption.length !== 0; + } + + const { notes, texts, shapes, images, edgelessTexts } = selected.reduce<{ + notes: NoteBlockModel[]; + texts: TextElementModel[]; + shapes: RemoveUndefinedKey[]; + images: RemoveUndefinedKey[]; + edgelessTexts: EdgelessTextBlockModel[]; + }>( + (pre, cur) => { + if (cur instanceof NoteBlockModel) { + pre.notes.push(cur); + } else if (cur instanceof TextElementModel) { + pre.texts.push(cur); + } else if (cur instanceof ShapeElementModel && isShapeWithText(cur)) { + pre.shapes.push(cur); + } else if (cur instanceof ImageBlockModel && isImageWithCaption(cur)) { + pre.images.push(cur); + } else if (cur instanceof EdgelessTextBlockModel) { + pre.edgelessTexts.push(cur); + } + + return pre; + }, + { notes: [], texts: [], shapes: [], images: [], edgelessTexts: [] } + ); + + const noteContent = await getContentFromHubBlockModel(host, notes); + const edgelessTextContent = await getContentFromHubBlockModel( + host, + edgelessTexts + ); + + return `${noteContent.join('\n')} + ${edgelessTextContent.join('\n')} +${texts.map(text => text.text.toString()).join('\n')} +${shapes.map(shape => shape.text.toString()).join('\n')} +${images.map(image => image.caption.toString()).join('\n')} +`.trim(); +} + +function getTextFromSelected(host: EditorHost) { + const selected = getCopilotSelectedElems(host); + return getContentFromSelected(host, selected); +} + +function actionToStream( + id: T, + signal?: AbortSignal, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + extract?: ( + host: EditorHost, + ctx: CtxRecord + ) => Promise<{ + content?: string; + attachments?: (string | Blob)[]; + seed?: string; + } | void>, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + const action = AIProvider.actions[id]; + + if (!action || typeof action !== 'function') return; + + if (extract && typeof extract === 'function') { + return (host: EditorHost, ctx: CtxRecord): BlockSuitePresets.TextStream => { + let stream: BlockSuitePresets.TextStream | undefined; + const control = trackerOptions?.control || 'format-bar'; + const where = trackerOptions?.where || 'ai-panel'; + return { + async *[Symbol.asyncIterator]() { + const models = getCopilotSelectedElems(host); + const options = { + ...variants, + signal, + input: '', + stream: true, + control, + where, + models, + host, + docId: host.doc.id, + workspaceId: host.doc.collection.id, + } as Parameters[0]; + + const data = await extract(host, ctx); + if (data) { + Object.assign(options, data); + } + + // @ts-expect-error todo: maybe fix this + stream = action(options); + if (!stream) return; + yield* stream; + }, + }; + }; + } + + return (host: EditorHost): BlockSuitePresets.TextStream => { + let stream: BlockSuitePresets.TextStream | undefined; + return { + async *[Symbol.asyncIterator]() { + const panel = getAIPanel(host); + const models = getCopilotSelectedElems(host); + const markdown = await getTextFromSelected(panel.host); + + const options = { + ...variants, + signal, + input: markdown, + stream: true, + where: 'ai-panel', + models, + control: 'format-bar', + host, + docId: host.doc.id, + workspaceId: host.doc.collection.id, + } as Parameters[0]; + + // @ts-expect-error todo: maybe fix this + stream = action(options); + if (!stream) return; + yield* stream; + }, + }; + }; +} + +function actionToGeneration( + id: T, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + extract?: ( + host: EditorHost, + ctx: CtxRecord + ) => Promise<{ + content?: string; + attachments?: (string | Blob)[]; + seed?: string; + } | void>, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return (host: EditorHost, ctx: CtxRecord) => { + return ({ + signal, + update, + finish, + }: { + input: string; + signal?: AbortSignal; + update: (text: string) => void; + finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void; + }) => { + if (!extract) { + const selectedElements = getCopilotSelectedElems(host); + if (selectedElements.length === 0) return; + } + + const stream = actionToStream( + id, + signal, + variants, + extract, + trackerOptions + )?.(host, ctx); + + if (!stream) return; + + bindTextStream(stream, { update, finish, signal }); + }; + }; +} + +function updateEdgelessAIPanelConfig< + T extends keyof BlockSuitePresets.AIActions, +>( + aiPanel: AffineAIPanelWidget, + edgelessCopilot: EdgelessCopilotWidget, + id: T, + generatingIcon: TemplateResult<1>, + ctx: CtxRecord, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + customInput?: ( + host: EditorHost, + ctx: CtxRecord + ) => Promise<{ + input?: string; + content?: string; + attachments?: (string | Blob)[]; + seed?: string; + } | void>, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + const host = aiPanel.host; + const { config } = aiPanel; + assertExists(config); + config.answerRenderer = actionToRenderer(id, host, ctx); + config.generateAnswer = actionToGeneration( + id, + variants, + customInput, + trackerOptions + )(host, ctx); + config.finishStateConfig = actionToResponse(id, host, ctx, variants); + config.generatingStateConfig = actionToGenerating(id, generatingIcon); + config.errorStateConfig = actionToErrorResponse( + aiPanel, + id, + host, + ctx, + variants + ); + config.copy = { + allowed: !EXCLUDING_COPY_ACTIONS.includes(id), + onCopy: () => { + return copyTextAnswer(aiPanel); + }, + }; + config.discardCallback = () => { + reportResponse('result:discard'); + }; + config.hideCallback = () => { + aiPanel.updateComplete + .finally(() => { + edgelessCopilot.edgeless.service.tool.switchToDefaultMode({ + elements: [], + editing: false, + }); + host.selection.clear(); + edgelessCopilot.lockToolbar(false); + }) + .catch(console.error); + }; +} + +export function actionToHandler( + id: T, + generatingIcon: TemplateResult<1>, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + >, + customInput?: ( + host: EditorHost, + ctx: CtxRecord + ) => Promise<{ + input?: string; + content?: string; + attachments?: (string | Blob)[]; + seed?: string; + } | void>, + trackerOptions?: BlockSuitePresets.TrackerOptions +) { + return (host: EditorHost) => { + const aiPanel = getAIPanel(host); + const edgelessCopilot = getEdgelessCopilotWidget(host); + let internal: Record = {}; + const selectedElements = getCopilotSelectedElems(host); + const { selectedBlocks } = getSelections(host); + const ctx = { + get() { + return { + ...internal, + selectedElements, + }; + }, + set(data: Record) { + internal = data; + }, + }; + + edgelessCopilot.hideCopilotPanel(); + edgelessCopilot.lockToolbar(true); + + aiPanel.host = host; + updateEdgelessAIPanelConfig( + aiPanel, + edgelessCopilot, + id, + generatingIcon, + ctx, + variants, + customInput, + trackerOptions + ); + + const elementToolbar = getElementToolbar(host); + const isEmpty = selectedElements.length === 0; + const isCreateImageAction = id === 'createImage'; + const isMakeItRealAction = !isCreateImageAction && id === 'makeItReal'; + let referenceElement = null; + let togglePanel = () => Promise.resolve(isEmpty); + + if (selectedBlocks && selectedBlocks.length !== 0) { + referenceElement = selectedBlocks.at(-1); + } else if (edgelessCopilot.visible && edgelessCopilot.selectionElem) { + referenceElement = edgelessCopilot.selectionElem; + } else if (elementToolbar.toolbarVisible) { + referenceElement = getElementToolbar(host); + } else if (!isEmpty) { + const lastSelected = selectedElements.at(-1)?.id; + assertExists(lastSelected); + referenceElement = getSelectedNoteAnchor(host, lastSelected); + } + + if (!referenceElement) return; + + if (isCreateImageAction || isMakeItRealAction) { + togglePanel = async () => { + if (isEmpty) return true; + const { + notes, + shapes, + images, + edgelessTexts, + frames: _, + } = BlocksUtils.splitElements(selectedElements); + const blocks = [...notes, ...shapes, ...images, ...edgelessTexts]; + if (blocks.length === 0) return true; + const content = await getContentFromSelected(host, blocks); + ctx.set({ + content, + }); + return content.length === 0; + }; + } + + togglePanel() + .then(isEmpty => { + aiPanel.toggle(referenceElement, isEmpty ? undefined : 'placeholder'); + }) + .catch(console.error); + }; +} + +export function noteBlockOrTextShowWhen( + _: unknown, + __: unknown, + host: EditorHost +) { + const selected = getCopilotSelectedElems(host); + + return selected.some( + el => + el instanceof NoteBlockModel || + el instanceof TextElementModel || + el instanceof EdgelessTextBlockModel + ); +} + +/** + * Checks if the selected element is a NoteBlockModel with a single child element of code block. + */ +export function noteWithCodeBlockShowWen( + _: unknown, + __: unknown, + host: EditorHost +) { + const selected = getCopilotSelectedElems(host); + if (!selected.length) return false; + + return ( + selected[0] instanceof NoteBlockModel && + selected[0].children.length === 1 && + BlocksUtils.matchFlavours(selected[0].children[0], ['affine:code']) + ); +} + +export function mindmapChildShowWhen( + _: unknown, + __: unknown, + host: EditorHost +) { + const selected = getCopilotSelectedElems(host); + + return selected.length === 1 && isMindmapChild(selected[0]); +} + +export function imageOnlyShowWhen(_: unknown, __: unknown, host: EditorHost) { + const selected = getCopilotSelectedElems(host); + + return selected.length === 1 && selected[0] instanceof ImageBlockModel; +} + +export function experimentalImageActionsShowWhen( + _: unknown, + __: unknown, + host: EditorHost +) { + return ( + !!host.doc.awarenessStore.getFlag('enable_new_image_actions') && + imageOnlyShowWhen(_, __, host) + ); +} + +export function mindmapRootShowWhen(_: unknown, __: unknown, host: EditorHost) { + const selected = getCopilotSelectedElems(host); + + return selected.length === 1 && isMindMapRoot(selected[0]); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts new file mode 100644 index 0000000000..ab2d809687 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/edgeless-response.ts @@ -0,0 +1,532 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { + AffineAIPanelWidget, + AIItemConfig, + EdgelessCopilotWidget, + EdgelessElementToolbarWidget, + EdgelessRootService, + MindmapElementModel, + ShapeElementModel, + SurfaceBlockModel, +} from '@blocksuite/blocks'; +import { + DeleteIcon, + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + EmbedHtmlBlockSpec, + fitContent, + ImageBlockModel, + InsertBelowIcon, + NoteDisplayMode, + ResetIcon, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; + +import { AIPenIcon, ChatWithAIIcon } from '../_common/icons.js'; +import { insertFromMarkdown } from '../_common/markdown-utils.js'; +import { getSurfaceElementFromEditor } from '../_common/selection-utils.js'; +import { getAIPanel } from '../ai-panel.js'; +import { AIProvider } from '../provider.js'; +import { reportResponse } from '../utils/action-reporter.js'; +import { + getEdgelessCopilotWidget, + getService, + isMindMapRoot, +} from '../utils/edgeless.js'; +import { preprocessHtml } from '../utils/html.js'; +import { fetchImageToFile } from '../utils/image.js'; +import { + getCopilotSelectedElems, + getEdgelessRootFromEditor, + getEdgelessService, +} from '../utils/selection-utils.js'; +import { EXCLUDING_INSERT_ACTIONS, generatingStages } from './consts.js'; +import type { CtxRecord } from './types.js'; + +type FinishConfig = Exclude< + AffineAIPanelWidget['config'], + null +>['finishStateConfig']; + +type ErrorConfig = Exclude< + AffineAIPanelWidget['config'], + null +>['errorStateConfig']; + +export function getElementToolbar( + host: EditorHost +): EdgelessElementToolbarWidget { + const rootBlockId = host.doc.root?.id as string; + const elementToolbar = host.view.getWidget( + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + rootBlockId + ) as EdgelessElementToolbarWidget; + + return elementToolbar; +} + +export function getTriggerEntry(host: EditorHost) { + const copilotWidget = getEdgelessCopilotWidget(host); + + return copilotWidget.visible ? 'selection' : 'toolbar'; +} + +export function discard( + panel: AffineAIPanelWidget, + _: EdgelessCopilotWidget +): AIItemConfig { + return { + name: 'Discard', + icon: DeleteIcon, + showWhen: () => !!panel.answer, + handler: () => { + panel.discard(); + }, + }; +} + +export function retry(panel: AffineAIPanelWidget): AIItemConfig { + return { + name: 'Retry', + icon: ResetIcon, + handler: () => { + reportResponse('result:retry'); + panel.generate(); + }, + }; +} + +export function createInsertResp( + id: T, + handler: (host: EditorHost, ctx: CtxRecord) => void, + host: EditorHost, + ctx: CtxRecord, + buttonText: string = 'Insert below' +): AIItemConfig { + return { + name: buttonText, + icon: InsertBelowIcon, + showWhen: () => { + const panel = getAIPanel(host); + return !EXCLUDING_INSERT_ACTIONS.includes(id) && !!panel.answer; + }, + handler: () => { + reportResponse('result:insert'); + handler(host, ctx); + const panel = getAIPanel(host); + panel.hide(); + }, + }; +} + +export function asCaption( + id: T, + host: EditorHost +): AIItemConfig { + return { + name: 'Use as caption', + icon: AIPenIcon, + showWhen: () => { + const panel = getAIPanel(host); + return id === 'generateCaption' && !!panel.answer; + }, + handler: () => { + reportResponse('result:use-as-caption'); + const panel = getAIPanel(host); + const caption = panel.answer; + if (!caption) return; + + const selectedElements = getCopilotSelectedElems(host); + if (selectedElements.length !== 1) return; + + const imageBlock = selectedElements[0]; + if (!(imageBlock instanceof ImageBlockModel)) return; + + host.doc.updateBlock(imageBlock, { caption }); + panel.hide(); + }, + }; +} + +type MindMapNode = { + text: string; + children: MindMapNode[]; +}; + +const defaultHandler = (host: EditorHost) => { + const doc = host.doc; + const panel = getAIPanel(host); + const edgelessCopilot = getEdgelessCopilotWidget(host); + const bounds = edgelessCopilot.determineInsertionBounds(800, 95); + + doc.transact(() => { + assertExists(doc.root); + const noteBlockId = doc.addBlock( + 'affine:note', + { + xywh: bounds.serialize(), + displayMode: NoteDisplayMode.EdgelessOnly, + }, + doc.root.id + ); + + assertExists(panel.answer); + insertFromMarkdown(host, panel.answer, noteBlockId) + .then(() => { + const service = getService(host); + + service.selection.set({ + elements: [noteBlockId], + editing: false, + }); + }) + .catch(err => { + console.error(err); + }); + }); +}; + +const imageHandler = (host: EditorHost) => { + const aiPanel = getAIPanel(host); + // `DataURL` or `URL` + const data = aiPanel.answer; + if (!data) return; + + const edgelessCopilot = getEdgelessCopilotWidget(host); + const bounds = edgelessCopilot.determineInsertionBounds(); + + edgelessCopilot.hideCopilotPanel(); + aiPanel.hide(); + + const filename = 'image'; + const imageProxy = host.std.clipboard.configs.get('imageProxy'); + + fetchImageToFile(data, filename, imageProxy) + .then(img => { + if (!img) return; + + const edgelessRoot = getEdgelessRootFromEditor(host); + const { minX, minY } = bounds; + const [x, y] = edgelessRoot.service.viewport.toViewCoord(minX, minY); + + host.doc.transact(() => { + edgelessRoot.addImages([img], [x, y], true).catch(console.error); + }); + }) + .catch(console.error); +}; + +export const responses: { + [key in keyof Partial]: ( + host: EditorHost, + ctx: CtxRecord + ) => void; +} = { + expandMindmap: (host, ctx) => { + const [surface] = host.doc.getBlockByFlavour( + 'affine:surface' + ) as SurfaceBlockModel[]; + + const elements = ctx.get()[ + 'selectedElements' + ] as BlockSuite.EdgelessModelType[]; + const data = ctx.get() as { + node: MindMapNode; + }; + + queueMicrotask(() => { + getAIPanel(host).hide(); + }); + + const mindmap = elements[0].group as MindmapElementModel; + + if (!data?.node) return; + + if (data.node.children) { + data.node.children.forEach(childTree => { + mindmap.addTree(elements[0].id, childTree); + }); + + const subtree = mindmap.getNode(elements[0].id); + + if (!subtree) return; + + surface.doc.transact(() => { + const updateNodeSize = (node: typeof subtree) => { + fitContent(node.element as ShapeElementModel); + + node.children.forEach(child => { + updateNodeSize(child); + }); + }; + + updateNodeSize(subtree); + }); + + setTimeout(() => { + const edgelessService = getEdgelessService(host); + + edgelessService.selection.set({ + elements: [subtree.element.id], + editing: false, + }); + }); + } + }, + brainstormMindmap: (host, ctx) => { + const aiPanel = getAIPanel(host); + const edgelessService = getEdgelessService(host); + const edgelessCopilot = getEdgelessCopilotWidget(host); + const selectionRect = edgelessCopilot.selectionModelRect; + const [surface] = host.doc.getBlockByFlavour( + 'affine:surface' + ) as SurfaceBlockModel[]; + const elements = ctx.get()[ + 'selectedElements' + ] as BlockSuite.EdgelessModelType[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = ctx.get() as any; + let newGenerated = true; + + // This means regenerate + if (isMindMapRoot(elements[0])) { + const mindmap = elements[0].group as MindmapElementModel; + const xywh = mindmap.tree.element.xywh; + + surface.removeElement(mindmap.id); + + if (data.node) { + data.node.xywh = xywh; + newGenerated = false; + } + } + + edgelessCopilot.hideCopilotPanel(); + aiPanel.hide(); + + const mindmapId = surface.addElement({ + type: 'mindmap', + children: data.node, + style: data.style, + }); + const mindmap = surface.getElementById(mindmapId) as MindmapElementModel; + + host.doc.transact(() => { + mindmap.childElements.forEach(shape => { + fitContent(shape as ShapeElementModel); + }); + }); + + edgelessService.telemetryService?.track('CanvasElementAdded', { + control: 'ai', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'mindmap', + }); + + queueMicrotask(() => { + if (newGenerated && selectionRect) { + mindmap.moveTo([ + selectionRect.x, + selectionRect.y, + selectionRect.width, + selectionRect.height, + ]); + } + }); + + // This is a workaround to make sure mindmap and other microtask are done + setTimeout(() => { + edgelessService.viewport.setViewportByBound( + mindmap.elementBound, + [20, 20, 20, 20], + true + ); + + edgelessService.selection.set({ + elements: [mindmap.tree.element.id], + editing: false, + }); + }); + }, + makeItReal: (host, ctx) => { + const aiPanel = getAIPanel(host); + let html = aiPanel.answer; + if (!html) return; + html = preprocessHtml(html); + + const edgelessCopilot = getEdgelessCopilotWidget(host); + const [surface] = host.doc.getBlockByFlavour( + 'affine:surface' + ) as SurfaceBlockModel[]; + + const data = ctx.get(); + const bounds = edgelessCopilot.determineInsertionBounds( + (data['width'] as number) || 800, + (data['height'] as number) || 600 + ); + + edgelessCopilot.hideCopilotPanel(); + aiPanel.hide(); + + const edgelessRoot = getEdgelessRootFromEditor(host); + + host.doc.transact(() => { + edgelessRoot.doc.addBlock( + EmbedHtmlBlockSpec.schema.model.flavour as 'affine:embed-html', + { + html, + design: 'ai:makeItReal', // as tag + xywh: bounds.serialize(), + }, + surface.id + ); + }); + }, + createSlides: (host, ctx) => { + const data = ctx.get(); + const contents = data.contents as unknown[]; + if (!contents) return; + const images = data.images as { url: string; id: string }[][]; + const service = host.spec.getService('affine:page'); + + (async function () { + for (let i = 0; i < contents.length - 1; i++) { + const image = images[i]; + const content = contents[i]; + const job = service.createTemplateJob('template'); + await Promise.all( + image.map(({ id, url }) => + fetch(url) + .then(res => res.blob()) + .then(blob => job.job.assets.set(id, blob)) + ) + ); + await job.insertTemplate(content); + getSurfaceElementFromEditor(host).refresh(); + } + })().catch(console.error); + }, + createImage: imageHandler, + processImage: imageHandler, + filterImage: imageHandler, +}; + +const getButtonText: { + [key in keyof Partial]: ( + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + > + ) => string | undefined; +} = { + brainstormMindmap: variants => { + return variants?.regenerate ? 'Replace' : undefined; + }, +}; + +export function getInsertAndReplaceHandler< + T extends keyof BlockSuitePresets.AIActions, +>( + id: T, + host: EditorHost, + ctx: CtxRecord, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + > +) { + const handler = responses[id] ?? defaultHandler; + const buttonText = getButtonText[id]?.(variants) ?? undefined; + + return createInsertResp(id, handler, host, ctx, buttonText); +} + +export function actionToResponse( + id: T, + host: EditorHost, + ctx: CtxRecord, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + > +): FinishConfig { + return { + responses: [ + { + name: 'Response', + items: [ + { + name: 'Continue in chat', + icon: ChatWithAIIcon, + handler: () => { + reportResponse('result:continue-in-chat'); + const panel = getAIPanel(host); + AIProvider.slots.requestContinueInChat.emit({ + host: host, + show: true, + }); + panel.hide(); + }, + }, + getInsertAndReplaceHandler(id, host, ctx, variants), + asCaption(id, host), + retry(getAIPanel(host)), + discard(getAIPanel(host), getEdgelessCopilotWidget(host)), + ], + }, + ], + actions: [], + }; +} + +export function actionToGenerating( + id: T, + generatingIcon: TemplateResult<1> +) { + return { + generatingIcon, + stages: generatingStages[id], + }; +} + +export function actionToErrorResponse< + T extends keyof BlockSuitePresets.AIActions, +>( + panel: AffineAIPanelWidget, + id: T, + host: EditorHost, + ctx: CtxRecord, + variants?: Omit< + Parameters[0], + keyof BlockSuitePresets.AITextActionOptions + > +): ErrorConfig { + return { + upgrade: () => { + AIProvider.slots.requestUpgradePlan.emit({ host: panel.host }); + panel.hide(); + }, + login: () => { + AIProvider.slots.requestLogin.emit({ host: panel.host }); + panel.hide(); + }, + cancel: () => { + panel.hide(); + }, + responses: [ + { + name: 'Response', + items: [getInsertAndReplaceHandler(id, host, ctx, variants)], + }, + { + name: '', + items: [ + retry(getAIPanel(host)), + discard(getAIPanel(host), getEdgelessCopilotWidget(host)), + ], + }, + ], + }; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/index.ts new file mode 100644 index 0000000000..2c2fa246fd --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/index.ts @@ -0,0 +1,2 @@ +export * from './doc-handler.js'; +export * from './types.js'; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts new file mode 100644 index 0000000000..041b931953 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/actions/types.ts @@ -0,0 +1,256 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +export const translateLangs = [ + 'English', + 'Spanish', + 'German', + 'French', + 'Italian', + 'Simplified Chinese', + 'Traditional Chinese', + 'Japanese', + 'Russian', + 'Korean', +] as const; + +export const textTones = [ + 'Professional', + 'Informal', + 'Friendly', + 'Critical', + 'Humorous', +] as const; + +export const imageFilterStyles = [ + 'Clay style', + 'Sketch style', + 'Anime style', + 'Pixel style', +] as const; + +export const imageProcessingTypes = [ + 'Clearer', + 'Remove background', + 'Convert to sticker', +] as const; + +export type CtxRecord = { + get(): Record; + set(data: Record): void; +}; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace BlockSuitePresets { + type TrackerControl = + | 'format-bar' + | 'slash-menu' + | 'chat-send' + | 'block-action-bar'; + + type TrackerWhere = 'chat-panel' | 'inline-chat-panel' | 'ai-panel'; + + interface TrackerOptions { + control: TrackerControl; + where: TrackerWhere; + } + + interface AITextActionOptions { + input?: string; + stream?: boolean; + attachments?: (string | File | Blob)[]; // blob could only be strings for the moments (url or data urls) + signal?: AbortSignal; + retry?: boolean; + + // action's context + docId: string; + workspaceId: string; + + // internal context + host: EditorHost; + models?: (BlockModel | BlockSuite.SurfaceElementModelType)[]; + control: TrackerControl; + where: TrackerWhere; + } + + interface AIImageActionOptions extends AITextActionOptions { + content?: string; + seed?: string; + } + + interface FilterImageOptions extends AIImageActionOptions { + style: (typeof imageFilterStyles)[number]; + } + + interface ProcessImageOptions extends AIImageActionOptions { + type: (typeof imageProcessingTypes)[number]; + } + + type TextStream = { + [Symbol.asyncIterator](): AsyncIterableIterator; + }; + + type AIActionTextResponse = + T['stream'] extends true ? TextStream : Promise; + + interface TranslateOptions extends AITextActionOptions { + lang: (typeof translateLangs)[number]; + } + + interface ChangeToneOptions extends AITextActionOptions { + tone: (typeof textTones)[number]; + } + + interface ExpandMindMap extends AITextActionOptions { + mindmap: string; + } + + interface BrainstormMindMap extends AITextActionOptions { + regenerate?: boolean; + } + + interface AIActions { + // chat is a bit special because it's has a internally maintained session + chat(options: T): AIActionTextResponse; + + summary( + options: T + ): AIActionTextResponse; + improveWriting( + options: T + ): AIActionTextResponse; + improveGrammar( + options: T + ): AIActionTextResponse; + fixSpelling( + options: T + ): AIActionTextResponse; + createHeadings( + options: T + ): AIActionTextResponse; + makeLonger( + options: T + ): AIActionTextResponse; + makeShorter( + options: T + ): AIActionTextResponse; + continueWriting( + options: T + ): AIActionTextResponse; + checkCodeErrors( + options: T + ): AIActionTextResponse; + explainCode( + options: T + ): AIActionTextResponse; + writeArticle( + options: T + ): AIActionTextResponse; + writeTwitterPost( + options: T + ): AIActionTextResponse; + writePoem( + options: T + ): AIActionTextResponse; + writeBlogPost( + options: T + ): AIActionTextResponse; + brainstorm( + options: T + ): AIActionTextResponse; + writeOutline( + options: T + ): AIActionTextResponse; + + explainImage( + options: T + ): AIActionTextResponse; + + findActions( + options: T + ): AIActionTextResponse; + + // mindmap + brainstormMindmap( + options: T + ): AIActionTextResponse; + expandMindmap( + options: T + ): AIActionTextResponse; + + // presentation + createSlides( + options: T + ): AIActionTextResponse; + + // explain this + explain( + options: T + ): AIActionTextResponse; + + // actions with variants + translate( + options: T + ): AIActionTextResponse; + changeTone( + options: T + ): AIActionTextResponse; + + // make it real, image to text + makeItReal( + options: T + ): AIActionTextResponse; + createImage( + options: T + ): AIActionTextResponse; + processImage( + options: T + ): AIActionTextResponse; + filterImage( + options: T + ): AIActionTextResponse; + generateCaption( + options: T + ): AIActionTextResponse; + } + + // todo: should be refactored to get rid of implement details (like messages, action, role, etc.) + interface AIHistory { + sessionId: string; + tokens: number; + action: string; + createdAt: string; + messages: { + content: string; + createdAt: string; + role: 'user' | 'assistant'; + }[]; + } + + interface AIHistoryService { + // non chat histories + actions: ( + workspaceId: string, + docId?: string + ) => Promise; + chats: ( + workspaceId: string, + docId?: string + ) => Promise; + cleanup: ( + workspaceId: string, + docId: string, + sessionIds: string[] + ) => Promise; + } + + interface AIPhotoEngineService { + searchImages(options: { + width: number; + height: number; + query: string; + }): Promise; + } + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/ai-panel.ts b/packages/frontend/core/src/blocksuite/presets/ai/ai-panel.ts new file mode 100644 index 0000000000..12ccd4a94b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/ai-panel.ts @@ -0,0 +1,394 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { + AFFINE_AI_PANEL_WIDGET, + AffineAIPanelWidget, + type AffineAIPanelWidgetConfig, + type AIItemConfig, + Bound, + ImageBlockModel, + isInsideEdgelessEditor, + matchFlavours, + NoteDisplayMode, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; + +import { + AIPenIcon, + AIStarIconWithAnimation, + ChatWithAIIcon, + CreateIcon, + DiscardIcon, + InsertBelowIcon, + InsertTopIcon, + ReplaceIcon, + RetryIcon, +} from './_common/icons.js'; +import { INSERT_ABOVE_ACTIONS } from './actions/consts.js'; +import { createTextRenderer } from './messages/text.js'; +import { AIProvider } from './provider.js'; +import { reportResponse } from './utils/action-reporter.js'; +import { findNoteBlockModel, getService } from './utils/edgeless.js'; +import { + copyTextAnswer, + insertAbove, + insertBelow, + replace, +} from './utils/editor-actions.js'; +import { insertFromMarkdown } from './utils/markdown-utils.js'; +import { getSelections } from './utils/selection-utils.js'; + +function getSelection(host: EditorHost) { + const textSelection = host.selection.find('text'); + const mode = textSelection ? 'flat' : 'highest'; + const { selectedBlocks } = getSelections(host, mode); + assertExists(selectedBlocks); + const length = selectedBlocks.length; + const firstBlock = selectedBlocks[0]; + const lastBlock = selectedBlocks[length - 1]; + const selectedModels = selectedBlocks.map(block => block.model); + return { + textSelection, + selectedModels, + firstBlock, + lastBlock, + }; +} + +function asCaption( + host: EditorHost, + id?: T +): AIItemConfig { + return { + name: 'Use as caption', + icon: AIPenIcon, + showWhen: () => { + const panel = getAIPanel(host); + return id === 'generateCaption' && !!panel.answer; + }, + handler: () => { + reportResponse('result:use-as-caption'); + const panel = getAIPanel(host); + const caption = panel.answer; + if (!caption) return; + + const { selectedBlocks } = getSelections(host); + if (!selectedBlocks || selectedBlocks.length !== 1) return; + + const imageBlock = selectedBlocks[0].model; + if (!(imageBlock instanceof ImageBlockModel)) return; + + host.doc.updateBlock(imageBlock, { caption }); + panel.hide(); + }, + }; +} + +function createNewNote(host: EditorHost): AIItemConfig { + return { + name: 'Create new note', + icon: CreateIcon, + showWhen: () => { + const panel = getAIPanel(host); + return !!panel.answer && isInsideEdgelessEditor(host); + }, + handler: () => { + reportResponse('result:add-note'); + // get the note block + const { selectedBlocks } = getSelections(host); + if (!selectedBlocks || !selectedBlocks.length) return; + const firstBlock = selectedBlocks[0]; + const noteModel = findNoteBlockModel(firstBlock); + if (!noteModel) return; + + // create a new note block at the left of the current note block + const bound = Bound.deserialize(noteModel.xywh); + const newBound = new Bound(bound.x - bound.w - 20, bound.y, bound.w, 72); + const doc = host.doc; + const panel = getAIPanel(host); + const service = getService(host); + doc.transact(() => { + assertExists(doc.root); + const noteBlockId = doc.addBlock( + 'affine:note', + { + xywh: newBound.serialize(), + displayMode: NoteDisplayMode.EdgelessOnly, + index: service.generateIndex('affine:note'), + }, + doc.root.id + ); + + assertExists(panel.answer); + insertFromMarkdown(host, panel.answer, noteBlockId) + .then(() => { + service.selection.set({ + elements: [noteBlockId], + editing: false, + }); + + // set the viewport to show the new note block and original note block + const newNote = doc.getBlock(noteBlockId)?.model; + if (!newNote || !matchFlavours(newNote, ['affine:note'])) return; + const newNoteBound = Bound.deserialize(newNote.xywh); + + const bounds = [bound, newNoteBound]; + const { zoom, centerX, centerY } = service.getFitToScreenData( + [20, 20, 20, 20], + bounds + ); + service.viewport.setViewport(zoom, [centerX, centerY]); + }) + .catch(err => { + console.error(err); + }); + }); + // hide the panel + panel.hide(); + }, + }; +} + +async function replaceWithAnswer(panel: AffineAIPanelWidget) { + const { host } = panel; + const selection = getSelection(host); + if (!selection || !panel.answer) return; + + const { textSelection, firstBlock, selectedModels } = selection; + await replace(host, panel.answer, firstBlock, selectedModels, textSelection); + + panel.hide(); +} + +async function insertAnswerBelow(panel: AffineAIPanelWidget) { + const { host } = panel; + const selection = getSelection(host); + + if (!selection || !panel.answer) { + return; + } + + const { lastBlock } = selection; + await insertBelow(host, panel.answer, lastBlock); + panel.hide(); +} + +async function insertAnswerAbove(panel: AffineAIPanelWidget) { + const { host } = panel; + const selection = getSelection(host); + if (!selection || !panel.answer) return; + + const { firstBlock } = selection; + await insertAbove(host, panel.answer, firstBlock); + panel.hide(); +} + +export function buildTextResponseConfig< + T extends keyof BlockSuitePresets.AIActions, +>(panel: AffineAIPanelWidget, id?: T) { + const host = panel.host; + + return [ + { + name: 'Response', + items: [ + { + name: 'Insert below', + icon: InsertBelowIcon, + showWhen: () => + !!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)), + handler: () => { + reportResponse('result:insert'); + insertAnswerBelow(panel).catch(console.error); + }, + }, + { + name: 'Insert above', + icon: InsertTopIcon, + showWhen: () => + !!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id), + handler: () => { + reportResponse('result:insert'); + insertAnswerAbove(panel).catch(console.error); + }, + }, + asCaption(host, id), + { + name: 'Replace selection', + icon: ReplaceIcon, + showWhen: () => !!panel.answer, + handler: () => { + reportResponse('result:replace'); + replaceWithAnswer(panel).catch(console.error); + }, + }, + createNewNote(host), + ], + }, + { + name: '', + items: [ + { + name: 'Continue in chat', + icon: ChatWithAIIcon, + handler: () => { + reportResponse('result:continue-in-chat'); + AIProvider.slots.requestContinueInChat.emit({ + host: panel.host, + show: true, + }); + panel.hide(); + }, + }, + { + name: 'Regenerate', + icon: RetryIcon, + handler: () => { + reportResponse('result:retry'); + panel.generate(); + }, + }, + { + name: 'Discard', + icon: DiscardIcon, + handler: () => { + panel.discard(); + }, + }, + ], + }, + ]; +} + +export function buildErrorResponseConfig< + T extends keyof BlockSuitePresets.AIActions, +>(panel: AffineAIPanelWidget, id?: T) { + const host = panel.host; + + return [ + { + name: 'Response', + items: [ + { + name: 'Replace selection', + icon: ReplaceIcon, + showWhen: () => !!panel.answer, + handler: () => { + replaceWithAnswer(panel).catch(console.error); + }, + }, + { + name: 'Insert below', + icon: InsertBelowIcon, + showWhen: () => + !!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)), + handler: () => { + insertAnswerBelow(panel).catch(console.error); + }, + }, + { + name: 'Insert above', + icon: InsertTopIcon, + showWhen: () => + !!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id), + handler: () => { + reportResponse('result:insert'); + insertAnswerAbove(panel).catch(console.error); + }, + }, + asCaption(host, id), + createNewNote(host), + ], + }, + { + name: '', + items: [ + { + name: 'Retry', + icon: RetryIcon, + showWhen: () => true, + handler: () => { + reportResponse('result:retry'); + panel.generate(); + }, + }, + { + name: 'Discard', + icon: DiscardIcon, + showWhen: () => !!panel.answer, + handler: () => { + panel.discard(); + }, + }, + ], + }, + ]; +} + +export function buildFinishConfig( + panel: AffineAIPanelWidget, + id?: T +) { + return { + responses: buildTextResponseConfig(panel, id), + actions: [], + }; +} + +export function buildErrorConfig( + panel: AffineAIPanelWidget, + id?: T +) { + return { + upgrade: () => { + AIProvider.slots.requestUpgradePlan.emit({ host: panel.host }); + panel.hide(); + }, + login: () => { + AIProvider.slots.requestLogin.emit({ host: panel.host }); + panel.hide(); + }, + cancel: () => { + panel.hide(); + }, + responses: buildErrorResponseConfig(panel, id), + }; +} + +export function buildGeneratingConfig(generatingIcon?: TemplateResult<1>) { + return { + generatingIcon: generatingIcon ?? AIStarIconWithAnimation, + }; +} + +export function buildCopyConfig(panel: AffineAIPanelWidget) { + return { + allowed: true, + onCopy: () => { + return copyTextAnswer(panel); + }, + }; +} + +export function buildAIPanelConfig( + panel: AffineAIPanelWidget +): AffineAIPanelWidgetConfig { + return { + answerRenderer: createTextRenderer(panel.host, { maxHeight: 320 }), + finishStateConfig: buildFinishConfig(panel), + generatingStateConfig: buildGeneratingConfig(), + errorStateConfig: buildErrorConfig(panel), + copy: buildCopyConfig(panel), + }; +} + +export const getAIPanel = (host: EditorHost): AffineAIPanelWidget => { + const rootBlockId = host.doc.root?.id; + assertExists(rootBlockId); + const aiPanel = host.view.getWidget(AFFINE_AI_PANEL_WIDGET, rootBlockId); + assertExists(aiPanel); + if (!(aiPanel instanceof AffineAIPanelWidget)) { + throw new Error('AI panel not found'); + } + return aiPanel; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts b/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts new file mode 100644 index 0000000000..1c89af9bf2 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts @@ -0,0 +1,156 @@ +import type { BlockSpec } from '@blocksuite/block-std'; +import { + AFFINE_AI_PANEL_WIDGET, + AFFINE_EDGELESS_COPILOT_WIDGET, + AffineAIPanelWidget, + AffineCodeToolbarWidget, + AffineFormatBarWidget, + AffineImageToolbarWidget, + AffineSlashMenuWidget, + CodeBlockSpec, + EdgelessCopilotWidget, + EdgelessElementToolbarWidget, + EdgelessRootBlockSpec, + ImageBlockSpec, + PageRootBlockSpec, + ParagraphBlockService, + ParagraphBlockSpec, +} from '@blocksuite/blocks'; +import { assertInstanceOf } from '@blocksuite/global/utils'; +import { literal, unsafeStatic } from 'lit/static-html.js'; + +import { buildAIPanelConfig } from './ai-panel.js'; +import { setupCodeToolbarEntry } from './entries/code-toolbar/setup-code-toolbar.js'; +import { + setupEdgelessCopilot, + setupEdgelessElementToolbarEntry, +} from './entries/edgeless/index.js'; +import { setupFormatBarEntry } from './entries/format-bar/setup-format-bar.js'; +import { setupImageToolbarEntry } from './entries/image-toolbar/setup-image-toolbar.js'; +import { setupSlashMenuEntry } from './entries/slash-menu/setup-slash-menu.js'; +import { setupSpaceEntry } from './entries/space/setup-space.js'; + +export const AIPageRootBlockSpec: BlockSpec = { + ...PageRootBlockSpec, + view: { + ...PageRootBlockSpec.view, + widgets: { + ...PageRootBlockSpec.view.widgets, + [AFFINE_AI_PANEL_WIDGET]: literal`${unsafeStatic( + AFFINE_AI_PANEL_WIDGET + )}`, + }, + }, + setup: (slots, disposableGroup) => { + PageRootBlockSpec.setup?.(slots, disposableGroup); + disposableGroup.add( + slots.widgetConnected.on(view => { + if (view.component instanceof AffineAIPanelWidget) { + view.component.style.width = '630px'; + view.component.config = buildAIPanelConfig(view.component); + setupSpaceEntry(view.component); + } + + if (view.component instanceof AffineFormatBarWidget) { + setupFormatBarEntry(view.component); + } + + if (view.component instanceof AffineSlashMenuWidget) { + setupSlashMenuEntry(view.component); + } + }) + ); + }, +}; + +export const AIEdgelessRootBlockSpec: BlockSpec = { + ...EdgelessRootBlockSpec, + view: { + ...EdgelessRootBlockSpec.view, + widgets: { + ...EdgelessRootBlockSpec.view.widgets, + [AFFINE_EDGELESS_COPILOT_WIDGET]: literal`${unsafeStatic( + AFFINE_EDGELESS_COPILOT_WIDGET + )}`, + [AFFINE_AI_PANEL_WIDGET]: literal`${unsafeStatic( + AFFINE_AI_PANEL_WIDGET + )}`, + }, + }, + setup(slots, disposableGroup) { + EdgelessRootBlockSpec.setup?.(slots, disposableGroup); + slots.widgetConnected.on(view => { + if (view.component instanceof AffineAIPanelWidget) { + view.component.style.width = '430px'; + view.component.config = buildAIPanelConfig(view.component); + setupSpaceEntry(view.component); + } + + if (view.component instanceof EdgelessCopilotWidget) { + setupEdgelessCopilot(view.component); + } + + if (view.component instanceof EdgelessElementToolbarWidget) { + setupEdgelessElementToolbarEntry(view.component); + } + + if (view.component instanceof AffineFormatBarWidget) { + setupFormatBarEntry(view.component); + } + + if (view.component instanceof AffineSlashMenuWidget) { + setupSlashMenuEntry(view.component); + } + }); + }, +}; + +export const AIParagraphBlockSpec: BlockSpec = { + ...ParagraphBlockSpec, + setup(slots, disposableGroup) { + ParagraphBlockSpec.setup?.(slots, disposableGroup); + slots.mounted.on(({ service }) => { + assertInstanceOf(service, ParagraphBlockService); + service.placeholderGenerator = model => { + if (model.type === 'text') { + return "Type '/' for commands, 'space' for AI"; + } + + const placeholders = { + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', + quote: '', + }; + return placeholders[model.type]; + }; + }); + }, +}; + +export const AICodeBlockSpec: BlockSpec = { + ...CodeBlockSpec, + setup(slots, disposableGroup) { + CodeBlockSpec.setup?.(slots, disposableGroup); + slots.widgetConnected.on(view => { + if (view.component instanceof AffineCodeToolbarWidget) { + setupCodeToolbarEntry(view.component); + } + }); + }, +}; + +export const AIImageBlockSpec: BlockSpec = { + ...ImageBlockSpec, + setup(slots, disposableGroup) { + ImageBlockSpec.setup?.(slots, disposableGroup); + slots.widgetConnected.on(view => { + if (view.component instanceof AffineImageToolbarWidget) { + setupImageToolbarEntry(view.component); + } + }); + }, +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/action-wrapper.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/action-wrapper.ts new file mode 100644 index 0000000000..7c4a490229 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/action-wrapper.ts @@ -0,0 +1,166 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/block-std'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { + ActionIcon, + AIChangeToneIcon, + AIDoneIcon, + AIExpandMindMapIcon, + AIExplainIcon, + AIExplainSelectionIcon, + AIFindActionsIcon, + AIImageIcon, + AIImproveWritingIcon, + AIMakeLongerIcon, + AIMakeRealIcon, + AIMakeShorterIcon, + AIMindMapIcon, + AIPenIcon, + AIPresentationIcon, + ArrowDownIcon, + ArrowUpIcon, +} from '../../_common/icons.js'; +import { createTextRenderer } from '../../messages/text.js'; +import type { ChatAction } from '../chat-context.js'; +import { renderImages } from '../components/images.js'; +import { HISTORY_IMAGE_ACTIONS } from '../const.js'; + +const icons: Record> = { + 'Fix spelling for it': AIDoneIcon, + 'Improve grammar for it': AIDoneIcon, + 'Explain this code': AIExplainIcon, + 'Check code error': AIExplainIcon, + 'Explain this': AIExplainSelectionIcon, + Translate: ActionIcon, + 'Change tone': AIChangeToneIcon, + 'Improve writing for it': AIImproveWritingIcon, + 'Make it longer': AIMakeLongerIcon, + 'Make it shorter': AIMakeShorterIcon, + 'Continue writing': AIPenIcon, + 'Make it real': AIMakeRealIcon, + 'Find action items from it': AIFindActionsIcon, + Summary: AIPenIcon, + 'Create headings': AIPenIcon, + 'Write outline': AIPenIcon, + image: AIImageIcon, + 'Brainstorm mindmap': AIMindMapIcon, + 'Expand mind map': AIExpandMindMapIcon, + 'Create a presentation': AIPresentationIcon, + 'Write a poem about this': AIPenIcon, + 'Write a blog post about this': AIPenIcon, + 'AI image filter clay style': AIImageIcon, + 'AI image filter sketch style': AIImageIcon, + 'AI image filter anime style': AIImageIcon, + 'AI image filter pixel style': AIImageIcon, + Clearer: AIImageIcon, + 'Remove background': AIImageIcon, + 'Convert to sticker': AIImageIcon, +}; + +@customElement('action-wrapper') +export class ActionWrapper extends WithDisposable(LitElement) { + static override styles = css` + .action-name { + display: flex; + align-items: center; + gap: 8px; + height: 22px; + margin-bottom: 12px; + + svg { + color: var(--affine-primary-color); + } + + div:last-child { + cursor: pointer; + display: flex; + align-items: center; + flex: 1; + + div:last-child svg { + margin-left: auto; + } + } + } + + .answer-prompt { + padding: 8px; + background-color: var(--affine-background-secondary-color); + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + font-weight: 400; + color: var(--affine-text-primary-color); + + .subtitle { + font-size: 12px; + font-weight: 500; + color: var(--affine-text-secondary-color); + height: 20px; + line-height: 20px; + } + + .prompt { + margin-top: 12px; + } + } + `; + + @state() + accessor promptShow = false; + + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + protected override render() { + const { item } = this; + + const originalText = item.messages[1].content; + const answer = item.messages[2]?.content; + const images = item.messages[1].attachments; + + return html` + +
(this.promptShow = !this.promptShow)} + > + ${icons[item.action] ? icons[item.action] : ActionIcon} +
+
${item.action}
+
${this.promptShow ? ArrowDownIcon : ArrowUpIcon}
+
+
+ ${this.promptShow + ? html` +
+
Answer
+ ${HISTORY_IMAGE_ACTIONS.includes(item.action) + ? images && renderImages(images) + : nothing} + ${answer + ? createTextRenderer(this.host, { customHeading: true })(answer) + : nothing} + ${originalText + ? html`
Prompt
+ ${createTextRenderer(this.host, { customHeading: true })( + item.messages[0].content + originalText + )}` + : nothing} +
+ ` + : nothing} `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-wrapper': ActionWrapper; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts new file mode 100644 index 0000000000..047f348777 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/actions-handle.ts @@ -0,0 +1,166 @@ +import type { + BlockSelection, + EditorHost, + TextSelection, +} from '@blocksuite/block-std'; +import type { + EdgelessRootService, + ImageSelection, + SerializedXYWH, +} from '@blocksuite/blocks'; +import { + BlocksUtils, + Bound, + getElementsBound, + NoteDisplayMode, +} from '@blocksuite/blocks'; + +import { + CreateIcon, + InsertBelowIcon, + ReplaceIcon, +} from '../../_common/icons.js'; +import { reportResponse } from '../../utils/action-reporter.js'; +import { insertBelow, replace } from '../../utils/editor-actions.js'; +import { insertFromMarkdown } from '../../utils/markdown-utils.js'; + +const { matchFlavours } = BlocksUtils; + +const CommonActions = [ + { + icon: ReplaceIcon, + title: 'Replace selection', + handler: async ( + host: EditorHost, + content: string, + currentTextSelection?: TextSelection, + currentBlockSelections?: BlockSelection[] + ) => { + const [_, data] = host.command + .chain() + .getSelectedBlocks({ + currentTextSelection, + currentBlockSelections, + }) + .run(); + if (!data.selectedBlocks) return; + + reportResponse('result:replace'); + + if (currentTextSelection) { + const { doc } = host; + const block = doc.getBlock(currentTextSelection.blockId); + if (matchFlavours(block?.model ?? null, ['affine:paragraph'])) { + block?.model.text?.replace( + currentTextSelection.from.index, + currentTextSelection.from.length, + content + ); + return; + } + } + + await replace( + host, + content, + data.selectedBlocks[0], + data.selectedBlocks.map(block => block.model), + currentTextSelection + ); + }, + }, + { + icon: InsertBelowIcon, + title: 'Insert below', + handler: async ( + host: EditorHost, + content: string, + currentTextSelection?: TextSelection, + currentBlockSelections?: BlockSelection[], + currentImageSelections?: ImageSelection[] + ) => { + const [_, data] = host.command + .chain() + .getSelectedBlocks({ + currentTextSelection, + currentBlockSelections, + currentImageSelections, + }) + .run(); + if (!data.selectedBlocks) return; + reportResponse('result:insert'); + await insertBelow( + host, + content, + data.selectedBlocks[data.selectedBlocks?.length - 1] + ); + }, + }, +]; + +export const PageEditorActions = [ + ...CommonActions, + { + icon: CreateIcon, + title: 'Create as a doc', + handler: (host: EditorHost, content: string) => { + reportResponse('result:add-page'); + const newDoc = host.doc.collection.createDoc(); + newDoc.load(); + const rootId = newDoc.addBlock('affine:page'); + newDoc.addBlock('affine:surface', {}, rootId); + const noteId = newDoc.addBlock('affine:note', {}, rootId); + + host.spec.getService('affine:page').slots.docLinkClicked.emit({ + docId: newDoc.id, + }); + let complete = false; + (function addContent() { + if (complete) return; + const newHost = document.querySelector('editor-host'); + // FIXME: this is a hack to wait for the host to be ready, now we don't have a way to know if the new host is ready + if (!newHost || newHost === host) { + setTimeout(addContent, 100); + return; + } + complete = true; + insertFromMarkdown(newHost, content, noteId, 0).catch(console.error); + })(); + }, + }, +]; + +export const EdgelessEditorActions = [ + ...CommonActions, + { + icon: CreateIcon, + title: 'Add to edgeless as note', + handler: async (host: EditorHost, content: string) => { + reportResponse('result:add-note'); + const { doc } = host; + const service = host.spec.getService('affine:page'); + const elements = service.selection.selectedElements; + + const props: { displayMode: NoteDisplayMode; xywh?: SerializedXYWH } = { + displayMode: NoteDisplayMode.EdgelessOnly, + }; + + if (elements.length > 0) { + const bound = getElementsBound( + elements.map(e => Bound.deserialize(e.xywh)) + ); + const newBound = new Bound(bound.x, bound.maxY + 10, bound.w); + props.xywh = newBound.serialize(); + } + + const id = doc.addBlock('affine:note', props, doc.root?.id); + + await insertFromMarkdown(host, content, id, 0); + + service.selection.set({ + elements: [id], + editing: false, + }); + }, + }, +]; 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 new file mode 100644 index 0000000000..80db6d3b0e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/chat-text.ts @@ -0,0 +1,39 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { createTextRenderer } from '../../messages/text.js'; +import { renderImages } from '../components/images.js'; +@customElement('chat-text') +export class ChatText extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor attachments: string[] | undefined = undefined; + + @property({ attribute: false }) + accessor text!: string; + + @property({ attribute: false }) + accessor state: 'finished' | 'generating' = 'finished'; + + protected override render() { + const { attachments, text, host } = this; + return html`${attachments && attachments.length > 0 + ? renderImages(attachments) + : nothing}${createTextRenderer(host, { customHeading: true })( + text, + this.state + )} `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-text': ChatText; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts new file mode 100644 index 0000000000..d7812437a4 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/copy-more.ts @@ -0,0 +1,226 @@ +import type { + BlockSelection, + EditorHost, + TextSelection, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/block-std'; +import { type AIError, createButtonPopper, Tooltip } from '@blocksuite/blocks'; +import { noop } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { CopyIcon, MoreIcon, RetryIcon } from '../../_common/icons.js'; +import { AIProvider } from '../../provider.js'; +import { copyText } from '../../utils/editor-actions.js'; +import type { ChatContextValue, ChatMessage } from '../chat-context.js'; +import { PageEditorActions } from './actions-handle.js'; + +noop(Tooltip); + +@customElement('chat-copy-more') +export class ChatCopyMore extends WithDisposable(LitElement) { + static override styles = css` + .copy-more { + display: flex; + gap: 8px; + height: 36px; + justify-content: flex-end; + align-items: center; + margin-top: 8px; + margin-bottom: 12px; + + div { + cursor: pointer; + border-radius: 4px; + } + + div:hover { + background-color: var(--affine-hover-color); + } + + svg { + color: var(--affine-icon-color); + } + } + + .more-menu { + width: 226px; + border-radius: 8px; + background-color: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-menu-shadow); + display: flex; + flex-direction: column; + gap: 4px; + position: absolute; + z-index: 1; + user-select: none; + + > div { + height: 30px; + display: flex; + gap: 8px; + align-items: center; + cursor: pointer; + + svg { + margin-left: 12px; + } + } + + > div:hover { + background-color: var(--affine-hover-color); + } + } + `; + + @state() + private accessor _showMoreMenu = false; + + @query('.more-button') + private accessor _moreButton!: HTMLDivElement; + + @query('.more-menu') + private accessor _moreMenu!: HTMLDivElement; + + private _morePopper: ReturnType | null = null; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor content!: string; + + @property({ attribute: false }) + accessor isLast!: boolean; + + @property({ attribute: false }) + accessor curTextSelection: TextSelection | undefined = undefined; + + @property({ attribute: false }) + accessor curBlockSelections: BlockSelection[] | undefined = undefined; + + @property({ attribute: false }) + accessor chatContextValue!: ChatContextValue; + + @property({ attribute: false }) + accessor updateContext!: (context: Partial) => void; + + private _toggle() { + this._morePopper?.toggle(); + } + + private async _retry() { + const { doc } = this.host; + try { + const abortController = new AbortController(); + + const items = [...this.chatContextValue.items]; + const last = items[items.length - 1]; + if ('content' in last) { + last.content = ''; + last.createdAt = new Date().toISOString(); + } + this.updateContext({ items, status: 'loading', error: null }); + + const stream = AIProvider.actions.chat?.({ + retry: true, + docId: doc.id, + workspaceId: doc.collection.id, + host: this.host, + stream: true, + signal: abortController.signal, + where: 'chat-panel', + control: 'chat-send', + }); + + if (stream) { + this.updateContext({ abortController }); + for await (const text of stream) { + const items = [...this.chatContextValue.items]; + const last = items[items.length - 1] as ChatMessage; + last.content += text; + this.updateContext({ items, status: 'transmitting' }); + } + + this.updateContext({ status: 'success' }); + } + } catch (error) { + this.updateContext({ status: 'error', error: error as AIError }); + } finally { + this.updateContext({ abortController: null }); + } + } + + protected override updated(changed: PropertyValues): void { + if (changed.has('isLast')) { + if (this.isLast) { + this._morePopper?.dispose(); + this._morePopper = null; + } else if (!this._morePopper) { + this._morePopper = createButtonPopper( + this._moreButton, + this._moreMenu, + ({ display }) => (this._showMoreMenu = display === 'show') + ); + } + } + } + + override render() { + const { host, content, isLast } = this; + return html` +
+ ${content + ? html`
copyText(host, content)}> + ${CopyIcon} + Copy +
` + : nothing} + ${isLast + ? html`
this._retry()}> + ${RetryIcon} + Retry +
` + : nothing} + ${isLast + ? nothing + : html`
+ ${MoreIcon} +
`} +
+ +
+ ${this._showMoreMenu + ? repeat( + PageEditorActions, + action => action.title, + action => { + return html`
+ action.handler( + host, + content, + this.curTextSelection, + this.curBlockSelections + )} + > + ${action.icon} +
${action.title}
+
`; + } + ) + : nothing} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-copy-more': ChatCopyMore; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/image-to-text.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/image-to-text.ts new file mode 100644 index 0000000000..7d7173b578 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/image-to-text.ts @@ -0,0 +1,35 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ChatAction } from '../chat-context.js'; +import { renderImages } from '../components/images.js'; + +@customElement('action-image-to-text') +export class ActionImageToText extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + protected override render() { + const answer = this.item.messages[1].attachments; + + return html` +
+ ${answer ? renderImages(answer) : nothing} +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-image-to-text': ActionImageToText; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/image.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/image.ts new file mode 100644 index 0000000000..ee7508d793 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/image.ts @@ -0,0 +1,35 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ChatAction } from '../chat-context.js'; +import { renderImages } from '../components/images.js'; + +@customElement('action-image') +export class ActionImage extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + protected override render() { + const answer = this.item.messages[0].attachments; + + return html` +
+ ${answer ? renderImages(answer) : nothing} +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-image': ActionImage; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/make-real.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/make-real.ts new file mode 100644 index 0000000000..024637faf8 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/make-real.ts @@ -0,0 +1,34 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { createIframeRenderer } from '../../messages/wrapper.js'; +import type { ChatAction } from '../chat-context.js'; + +@customElement('action-make-real') +export class ActionMakeReal extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + protected override render() { + const answer = this.item.messages[2].content; + return html` +
+ ${createIframeRenderer(this.host)(answer, 'finished')} +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-make-real': ActionMakeReal; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/mindmap.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/mindmap.ts new file mode 100644 index 0000000000..dba11f5a5c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/mindmap.ts @@ -0,0 +1,46 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { MiniMindmapPreview } from '@blocksuite/blocks'; +import { noop } from '@blocksuite/global/utils'; +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ChatAction } from '../chat-context.js'; + +noop(MiniMindmapPreview); + +@customElement('action-mindmap') +export class ActionMindmap extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + protected override render() { + const answer = this.item.messages[2].content; + return html` +
+ ({}), + set: () => {}, + }} + .answer=${answer} + .templateShow=${false} + .height=${140} + > +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-mindmap': ActionMindmap; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/slides.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/slides.ts new file mode 100644 index 0000000000..aaf54022d6 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/slides.ts @@ -0,0 +1,39 @@ +import './action-wrapper.js'; +import '../../messages/slides-renderer.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ChatAction } from '../chat-context.js'; + +@customElement('action-slides') +export class ActionSlides extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + protected override render() { + const answer = this.item.messages[2]?.content; + if (!answer) return nothing; + + return html` +
+ +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-slides': ActionSlides; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/text.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/text.ts new file mode 100644 index 0000000000..e4f9d19684 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/actions/text.ts @@ -0,0 +1,57 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/block-std'; +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { createTextRenderer } from '../../messages/text.js'; +import type { ChatAction } from '../chat-context.js'; + +@customElement('action-text') +export class ActionText extends WithDisposable(LitElement) { + static override styles = css` + .original-text { + border-radius: 4px; + margin-bottom: 12px; + font-size: var(--affine-font-sm); + line-height: 22px; + } + `; + + @property({ attribute: false }) + accessor item!: ChatAction; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor isCode = false; + + protected override render() { + const originalText = this.item.messages[1].content; + const { isCode } = this; + + return html` +
+ ${createTextRenderer(this.host, { + customHeading: true, + maxHeight: 160, + })(originalText)} +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'action-text': ActionText; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/ai-loading.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/ai-loading.ts new file mode 100644 index 0000000000..67744a6e5a --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/ai-loading.ts @@ -0,0 +1,65 @@ +import { WithDisposable } from '@blocksuite/block-std'; +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { AIStarIconWithAnimation } from '../_common/icons.js'; + +@customElement('ai-loading') +export class AILoading extends WithDisposable(LitElement) { + static override styles = css` + :host { + width: 100%; + } + + .generating-tip { + display: flex; + width: 100%; + height: 22px; + align-items: center; + gap: 8px; + + color: var(--light-brandColor, #1e96eb); + + .text { + display: flex; + align-items: flex-start; + gap: 10px; + flex: 1 0 0; + + /* light/smMedium */ + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; /* 157.143% */ + } + + .left, + .right { + display: flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + } + } + `; + + @property({ attribute: false }) + accessor stopGenerating!: () => void; + + override render() { + return html` +
+
${AIStarIconWithAnimation}
+
AI is generating...
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-loading': AILoading; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-cards.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-cards.ts new file mode 100644 index 0000000000..d269692a4e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-cards.ts @@ -0,0 +1,359 @@ +import type { BaseSelection, EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/block-std'; +import { + type CopilotSelectionController, + type ImageBlockModel, + type NoteBlockModel, + NoteDisplayMode, +} from '@blocksuite/blocks'; +import { debounce } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + CurrentSelectionIcon, + DocIcon, + SmallImageIcon, +} from '../_common/icons.js'; +import { + getEdgelessRootFromEditor, + getSelectedImagesAsBlobs, + getSelectedTextContent, + getTextContentFromBlockModels, + selectedToCanvas, +} from '../utils/selection-utils.js'; +import type { ChatContextValue } from './chat-context.js'; + +const cardsStyles = css` + .card-wrapper { + width: 90%; + max-height: 76px; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + padding: 4px 12px; + cursor: pointer; + + .card-title { + display: flex; + gap: 4px; + height: 22px; + margin-bottom: 2px; + font-weight: 500; + font-size: 14px; + color: var(--affine-text-primary-color); + } + + .second-text { + font-size: 14px; + font-weight: 400; + color: var(--affine-text-secondary-color); + } + } +`; + +const ChatCardsConfig = [ + { + name: 'current-selection', + render: (text?: string, _?: File, __?: string) => { + if (!text) return nothing; + + const lines = text.split('\n'); + + return html`
+
+ ${CurrentSelectionIcon} +
Start with current selection
+
+
+ ${repeat( + lines.slice(0, 2), + line => line, + line => { + return html`
+ ${line} +
`; + } + )} +
+
`; + }, + handler: ( + updateContext: (context: Partial) => void, + text: string, + markdown: string, + images?: File[] + ) => { + const value: Partial = { + quote: text, + markdown: markdown, + }; + if (images) { + value.images = images; + } + updateContext(value); + }, + }, + { + name: 'image', + render: (_?: string, image?: File, caption?: string) => { + if (!image) return nothing; + + return html`
+
+
+ ${SmallImageIcon} +
Start with this Image
+
+
${caption ? caption : 'caption'}
+
+ +
`; + }, + handler: ( + updateContext: (context: Partial) => void, + _: string, + __: string, + images?: File[] + ) => { + const value: Partial = {}; + if (images) { + value.images = images; + } + updateContext(value); + }, + }, + { + name: 'doc', + render: () => { + return html` +
+
+ ${DocIcon} +
Start with this doc
+
+
you've chosen within the doc
+
+ `; + }, + handler: ( + updateContext: (context: Partial) => void, + text: string, + markdown: string, + images?: File[] + ) => { + const value: Partial = { + quote: text, + markdown: markdown, + }; + if (images) { + value.images = images; + } + updateContext(value); + }, + }, +]; + +@customElement('chat-cards') +export class ChatCards extends WithDisposable(LitElement) { + static override styles = css` + ${cardsStyles} + .cards-container { + display: flex; + flex-direction: column; + gap: 12px; + } + `; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor chatContextValue!: ChatContextValue; + + @property({ attribute: false }) + accessor updateContext!: (context: Partial) => void; + + @property({ attribute: false }) + accessor selectionValue: BaseSelection[] = []; + + @state() + accessor text: string = ''; + + @state() + accessor markdown: string = ''; + + @state() + accessor images: File[] = []; + + @state() + accessor caption: string = ''; + + private _onEdgelessCopilotAreaUpdated() { + if (!this.host.closest('edgeless-editor')) return; + const edgeless = getEdgelessRootFromEditor(this.host); + + const copilotSelectionTool = edgeless.tools.controllers + .copilot as CopilotSelectionController; + + this._disposables.add( + copilotSelectionTool.draggingAreaUpdated.on( + debounce(() => { + selectedToCanvas(this.host) + .then(canvas => { + canvas?.toBlob(blob => { + if (!blob) return; + const file = new File([blob], 'selected.png'); + this.images = [file]; + }); + }) + .catch(console.error); + }, 300) + ) + ); + } + + private async _updateState() { + if ( + this.selectionValue.some( + selection => selection.is('text') || selection.is('image') + ) + ) + return; + this.text = await getSelectedTextContent(this.host, 'plain-text'); + this.markdown = await getSelectedTextContent(this.host, 'markdown'); + this.images = await getSelectedImagesAsBlobs(this.host); + const [_, data] = this.host.command + .chain() + .tryAll(chain => [ + chain.getTextSelection(), + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedBlocks({ + types: ['image'], + }) + .run(); + if (data.currentBlockSelections?.[0]) { + this.caption = + ( + this.host.doc.getBlock(data.currentBlockSelections[0].blockId) + ?.model as ImageBlockModel + ).caption ?? ''; + } + } + + private async _handleDocSelection() { + const notes = this.host.doc + .getBlocksByFlavour('affine:note') + .filter( + note => + (note.model as NoteBlockModel).displayMode !== + NoteDisplayMode.EdgelessOnly + ) + .map(note => note.model as NoteBlockModel); + const selectedModels = notes.reduce((acc, note) => { + acc.push(...note.children); + return acc; + }, [] as BlockModel[]); + const text = await getTextContentFromBlockModels( + this.host, + selectedModels, + 'plain-text' + ); + const markdown = await getTextContentFromBlockModels( + this.host, + selectedModels, + 'markdown' + ); + const blobs = await Promise.all( + selectedModels.map(async s => { + if (s.flavour !== 'affine:image') return null; + const sourceId = (s as ImageBlockModel)?.sourceId; + if (!sourceId) return null; + const blob = await (sourceId + ? this.host.doc.blobSync.get(sourceId) + : null); + if (!blob) return null; + return new File([blob], sourceId); + }) ?? [] + ); + const images = blobs.filter((blob): blob is File => !!blob); + this.text = text; + this.markdown = markdown; + this.images = images; + } + + protected override async updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('selectionValue')) { + await this._updateState(); + } + + if (_changedProperties.has('host')) { + this._onEdgelessCopilotAreaUpdated(); + } + } + + protected override render() { + return html`
+ ${repeat( + ChatCardsConfig, + card => card.name, + card => { + if ( + card.render(this.text, this.images[0], this.caption) !== nothing + ) { + return html`
{ + if (card.name === 'doc') { + await this._handleDocSelection(); + } + card.handler( + this.updateContext, + this.text, + this.markdown, + this.images + ); + }} + > + ${card.render(this.text, this.images[0], this.caption)} +
`; + } + return nothing; + } + )} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-cards': ChatCards; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts new file mode 100644 index 0000000000..7a4a708771 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-context.ts @@ -0,0 +1,34 @@ +import type { AIError } from '@blocksuite/blocks'; + +export type ChatMessage = { + content: string; + role: 'user' | 'assistant'; + attachments?: string[]; + createdAt: string; +}; + +export type ChatAction = { + action: string; + messages: ChatMessage[]; + sessionId: string; + createdAt: string; +}; + +export type ChatItem = ChatMessage | ChatAction; + +export type ChatStatus = + | 'loading' + | 'success' + | 'error' + | 'idle' + | 'transmitting'; + +export type ChatContextValue = { + items: ChatItem[]; + status: ChatStatus; + error: AIError | null; + quote: string; + markdown: string; + images: File[]; + abortController: AbortController | null; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts new file mode 100644 index 0000000000..9d90bbfa8d --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts @@ -0,0 +1,484 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/block-std'; +import { type AIError, openFileOrFiles } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + ChatAbortIcon, + ChatClearIcon, + ChatSendIcon, + CloseIcon, + ImageIcon, +} from '../_common/icons.js'; +import { AIProvider } from '../provider.js'; +import { reportResponse } from '../utils/action-reporter.js'; +import { readBlobAsURL } from '../utils/image.js'; +import type { ChatContextValue, ChatMessage } from './chat-context.js'; + +const MaximumImageCount = 8; + +function getFirstTwoLines(text: string) { + const lines = text.split('\n'); + return lines.slice(0, 2); +} + +@customElement('chat-panel-input') +export class ChatPanelInput extends WithDisposable(LitElement) { + static override styles = css` + .chat-panel-input { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; + position: relative; + margin-top: 12px; + border-radius: 4px; + padding: 8px; + min-height: 94px; + box-sizing: border-box; + border-width: 1px; + border-style: solid; + + .chat-selection-quote { + padding: 4px 0px 8px 0px; + padding-left: 15px; + max-height: 56px; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--affine-text-secondary-color); + position: relative; + + div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-quote-close { + position: absolute; + right: 0; + top: 0; + cursor: pointer; + display: none; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + background-color: var(--affine-white); + } + } + + .chat-selection-quote:hover .chat-quote-close { + display: flex; + justify-content: center; + align-items: center; + } + + .chat-selection-quote::after { + content: ''; + width: 2px; + height: calc(100% - 10px); + margin-top: 5px; + position: absolute; + left: 0; + top: 0; + background: var(--affine-quote-color); + border-radius: 18px; + } + } + + .chat-panel-input-actions { + display: flex; + gap: 8px; + align-items: center; + + div { + width: 24px; + height: 24px; + cursor: pointer; + } + + div:nth-child(2) { + margin-left: auto; + } + + .chat-history-clear { + background-color: var(--affine-white); + } + + .image-upload { + background-color: var(--affine-white); + display: flex; + justify-content: center; + align-items: center; + } + } + + .chat-panel-input { + textarea { + width: 100%; + padding: 0; + margin: 0; + border: none; + line-height: 22px; + font-size: var(--affine-font-sm); + font-weight: 400; + font-family: var(--affine-font-family); + color: var(--affine-text-primary-color); + box-sizing: border-box; + resize: none; + overflow-y: hidden; + } + + textarea::placeholder { + font-size: 14px; + font-weight: 400; + font-family: var(--affine-font-family); + color: var(--affine-placeholder-color); + } + + textarea:focus { + outline: none; + } + } + + .chat-panel-images { + display: flex; + gap: 4px; + flex-wrap: wrap; + position: relative; + + .image-container { + width: 58px; + height: 58px; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + cursor: pointer; + overflow: hidden; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + } + } + } + + .close-wrapper { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + justify-content: center; + align-items: center; + display: none; + position: absolute; + background-color: var(--affine-white); + z-index: 1; + cursor: pointer; + } + + .close-wrapper:hover { + background-color: var(--affine-background-error-color); + border: 1px solid var(--affine-error-color); + } + + .close-wrapper:hover svg path { + fill: var(--affine-error-color); + } + `; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @query('.chat-panel-images') + accessor imagesWrapper!: HTMLDivElement; + + @query('textarea') + accessor textarea!: HTMLTextAreaElement; + + @query('.close-wrapper') + accessor closeWrapper!: HTMLDivElement; + + @state() + accessor curIndex = -1; + + @state() + accessor isInputEmpty = true; + + @state() + accessor focused = false; + + @property({ attribute: false }) + accessor chatContextValue!: ChatContextValue; + + @property({ attribute: false }) + accessor updateContext!: (context: Partial) => void; + + @property({ attribute: false }) + accessor cleanupHistories!: () => Promise; + + private _addImages(images: File[]) { + const oldImages = this.chatContextValue.images; + this.updateContext({ + images: [...oldImages, ...images].slice(0, MaximumImageCount), + }); + } + + private _renderImages(images: File[]) { + return html` +
{ + this.closeWrapper.style.display = 'none'; + this.curIndex = -1; + }} + > + ${repeat( + images, + image => image.name, + (image, index) => + html`
{ + const ele = evt.target as HTMLImageElement; + const rect = ele.getBoundingClientRect(); + assertExists(ele.parentElement); + const parentRect = ele.parentElement.getBoundingClientRect(); + const left = Math.abs(rect.right - parentRect.left) - 8; + const top = Math.abs(parentRect.top - rect.top) - 8; + this.curIndex = index; + this.closeWrapper.style.display = 'flex'; + this.closeWrapper.style.left = left + 'px'; + this.closeWrapper.style.top = top + 'px'; + }} + > + ${image.name} +
` + )} +
{ + if (this.curIndex >= 0 && this.curIndex < images.length) { + const newImages = [...images]; + newImages.splice(this.curIndex, 1); + this.updateContext({ images: newImages }); + this.curIndex = -1; + this.closeWrapper.style.display = 'none'; + } + }} + > + ${CloseIcon} +
+
+ `; + } + + protected override render() { + const { images, status } = this.chatContextValue; + const hasImages = images.length > 0; + const maxHeight = hasImages ? 272 + 2 : 200 + 2; + + return html` +
+ ${hasImages ? this._renderImages(images) : nothing} + ${this.chatContextValue.quote + ? html`
+ ${repeat( + getFirstTwoLines(this.chatContextValue.quote), + line => line, + line => html`
${line}
` + )} +
{ + this.updateContext({ quote: '', markdown: '' }); + }} + > + ${CloseIcon} +
+
` + : nothing} + +
+
{ + await this.cleanupHistories(); + }} + > + ${ChatClearIcon} +
+ ${images.length < MaximumImageCount + ? html`
{ + const images = await openFileOrFiles({ + acceptType: 'Images', + multiple: true, + }); + if (!images) return; + this._addImages(images); + }} + > + ${ImageIcon} +
` + : nothing} + ${status === 'transmitting' + ? html`
{ + this.chatContextValue.abortController?.abort(); + this.updateContext({ status: 'success' }); + reportResponse('aborted:stop'); + }} + > + ${ChatAbortIcon} +
` + : html`
+ ${ChatSendIcon} +
`} +
+
`; + } + + send = async () => { + const { status, markdown } = this.chatContextValue; + if (status === 'loading' || status === 'transmitting') return; + + const text = this.textarea.value; + const { images } = this.chatContextValue; + if (!text && images.length === 0) { + return; + } + const { doc } = this.host; + this.textarea.value = ''; + this.isInputEmpty = true; + this.updateContext({ + images: [], + status: 'loading', + error: null, + quote: '', + markdown: '', + }); + + const attachments = await Promise.all( + images?.map(image => readBlobAsURL(image)) + ); + + const content = (markdown ? `${markdown}\n` : '') + text; + + this.updateContext({ + items: [ + ...this.chatContextValue.items, + { + role: 'user', + content: content, + createdAt: new Date().toISOString(), + attachments, + }, + { role: 'assistant', content: '', createdAt: new Date().toISOString() }, + ], + }); + + try { + const abortController = new AbortController(); + const stream = AIProvider.actions.chat?.({ + input: content, + docId: doc.id, + attachments: images, + workspaceId: doc.collection.id, + host: this.host, + stream: true, + signal: abortController.signal, + where: 'chat-panel', + control: 'chat-send', + }); + + if (stream) { + this.updateContext({ abortController }); + + for await (const text of stream) { + const items = [...this.chatContextValue.items]; + const last = items[items.length - 1] as ChatMessage; + last.content += text; + this.updateContext({ items, status: 'transmitting' }); + } + + this.updateContext({ status: 'success' }); + } + } catch (error) { + this.updateContext({ status: 'error', error: error as AIError }); + } finally { + this.updateContext({ abortController: null }); + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-panel-input': ChatPanelInput; + } +} 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 new file mode 100644 index 0000000000..a7553d802e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts @@ -0,0 +1,523 @@ +import '../messages/slides-renderer.js'; +import './ai-loading.js'; +import '../messages/text.js'; +import './actions/text.js'; +import './actions/action-wrapper.js'; +import './actions/make-real.js'; +import './actions/slides.js'; +import './actions/mindmap.js'; +import './actions/chat-text.js'; +import './actions/copy-more.js'; +import './actions/image-to-text.js'; +import './actions/image.js'; +import './chat-cards.js'; + +import type { + BaseSelection, + BlockSelection, + EditorHost, + TextSelection, +} from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import type { ImageSelection } from '@blocksuite/blocks'; +import { + isInsidePageEditor, + PaymentRequiredError, + UnauthorizedError, +} from '@blocksuite/blocks'; +import { css, html, nothing, type PropertyValues } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + AffineAvatarIcon, + AffineIcon, + DownArrowIcon, +} from '../_common/icons.js'; +import { + GeneralErrorRenderer, + PaymentRequiredErrorRenderer, +} from '../messages/error.js'; +import { AIProvider } from '../provider.js'; +import { insertBelow } from '../utils/editor-actions.js'; +import { + EdgelessEditorActions, + PageEditorActions, +} from './actions/actions-handle.js'; +import type { + ChatContextValue, + ChatItem, + ChatMessage, +} from './chat-context.js'; +import { HISTORY_IMAGE_ACTIONS } from './const.js'; + +@customElement('chat-panel-messages') +export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { + private get _currentTextSelection(): TextSelection | undefined { + return this._selectionValue.find(v => v.type === 'text') as TextSelection; + } + + private get _currentBlockSelections(): BlockSelection[] | undefined { + return this._selectionValue.filter(v => v.type === 'block'); + } + + private get _currentImageSelections(): ImageSelection[] | undefined { + return this._selectionValue.filter(v => v.type === 'image'); + } + + static override styles = css` + chat-panel-messages { + position: relative; + } + + .chat-panel-messages { + display: flex; + flex-direction: column; + gap: 24px; + height: 100%; + position: relative; + overflow-y: auto; + + chat-cards { + position: absolute; + bottom: 0; + width: 100%; + } + } + + .chat-panel-messages-placeholder { + width: 100%; + position: absolute; + z-index: 1; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + } + + .item-wrapper { + margin-left: 32px; + } + + .user-info { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; + color: var(--affine-text-primary-color); + font-size: 14px; + font-weight: 500; + user-select: none; + } + + .avatar-container { + width: 24px; + height: 24px; + } + + .avatar { + width: 100%; + height: 100%; + border-radius: 50%; + background-color: var(--affine-primary-color); + } + + .avatar-container img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + + .down-indicator { + position: absolute; + left: 50%; + transform: translate(-50%, 0); + bottom: 24px; + z-index: 1; + border-radius: 50%; + width: 32px; + height: 32px; + border: 0.5px solid var(--affine-border-color); + background-color: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-2); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + `; + + private _selectionValue: BaseSelection[] = []; + + @state() + accessor showDownIndicator = false; + + @state() + accessor avatarUrl = ''; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor isLoading!: boolean; + + @property({ attribute: false }) + accessor chatContextValue!: ChatContextValue; + + @property({ attribute: false }) + accessor updateContext!: (context: Partial) => void; + + @query('.chat-panel-messages') + accessor messagesContainer!: HTMLDivElement; + + protected override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('host')) { + const { disposables } = this; + + disposables.add( + this.host.selection.slots.changed.on(() => { + this._selectionValue = this.host.selection.value; + this.requestUpdate(); + }) + ); + const { docModeService } = this.host.spec.getService('affine:page'); + disposables.add(docModeService.onModeChange(() => this.requestUpdate())); + } + } + + protected override render() { + const { items } = this.chatContextValue; + const { isLoading } = this; + const filteredItems = items.filter(item => { + return ( + 'role' in item || + item.messages?.length === 3 || + (HISTORY_IMAGE_ACTIONS.includes(item.action) && + item.messages?.length === 2) + ); + }); + + return html` + +
{ + const element = evt.target as HTMLDivElement; + this.showDownIndicator = + element.scrollHeight - element.scrollTop - element.clientHeight > + 200; + }} + > + ${items.length === 0 + ? html`
+ ${AffineIcon( + isLoading + ? 'var(--affine-icon-secondary)' + : 'var(--affine-primary-color)' + )} +
+ ${this.isLoading + ? 'AFFiNE AI is loading history...' + : 'What can I help you with?'} +
+
+ ` + : repeat(filteredItems, (item, index) => { + const isLast = index === filteredItems.length - 1; + return html`
+ ${this.renderAvatar(item)} +
${this.renderItem(item, isLast)}
+
`; + })} +
+ ${this.showDownIndicator + ? html`
this.scrollToDown()}> + ${DownArrowIcon} +
` + : nothing} `; + } + + override async connectedCallback() { + super.connectedCallback(); + + const res = await AIProvider.userInfo; + this.avatarUrl = res?.avatarUrl ?? ''; + this.disposables.add( + AIProvider.slots.userInfo.on(userInfo => { + const { status, error } = this.chatContextValue; + this.avatarUrl = userInfo?.avatarUrl ?? ''; + if ( + status === 'error' && + error instanceof UnauthorizedError && + userInfo + ) { + this.updateContext({ status: 'idle', error: null }); + } + }) + ); + } + + renderError() { + const { error } = this.chatContextValue; + + if (error instanceof PaymentRequiredError) { + return PaymentRequiredErrorRenderer(this.host); + } else if (error instanceof UnauthorizedError) { + return GeneralErrorRenderer( + html`You need to login to AFFiNE Cloud to continue using AFFiNE AI.`, + html`
+ AIProvider.slots.requestLogin.emit({ host: this.host })} + > + Login +
` + ); + } else { + return GeneralErrorRenderer(); + } + } + + renderItem(item: ChatItem, isLast: boolean) { + const { status, error } = this.chatContextValue; + + if (isLast && status === 'loading') { + return this.renderLoading(); + } + + if ( + isLast && + status === 'error' && + (error instanceof PaymentRequiredError || + error instanceof UnauthorizedError) + ) { + return this.renderError(); + } + + if ('role' in item) { + const state = isLast + ? status !== 'loading' && status !== 'transmitting' + ? 'finished' + : 'generating' + : 'finished'; + return html` + ${isLast && status === 'error' ? this.renderError() : nothing} + ${this.renderEditorActions(item, isLast)}`; + } else { + switch (item.action) { + case 'Create a presentation': + return html``; + case 'Make it real': + return html``; + case 'Brainstorm mindmap': + return html``; + case 'Explain this image': + case 'Generate a caption': + return html``; + default: + if (HISTORY_IMAGE_ACTIONS.includes(item.action)) { + return html``; + } + + return html``; + } + } + } + + renderAvatar(item: ChatItem) { + const isUser = 'role' in item && item.role === 'user'; + + return html``; + } + + renderLoading() { + return html` `; + } + + scrollToDown() { + this.messagesContainer.scrollTo(0, this.messagesContainer.scrollHeight); + } + + renderEditorActions(item: ChatMessage, isLast: boolean) { + const { status } = this.chatContextValue; + + if (item.role !== 'assistant') return nothing; + + if ( + isLast && + status !== 'success' && + status !== 'idle' && + status !== 'error' + ) + return nothing; + + const { host } = this; + const { content } = item; + const actions = isInsidePageEditor(host) + ? PageEditorActions + : EdgelessEditorActions; + + return html` + + + ${isLast + ? html`
+ ${repeat( + actions.filter(action => { + if (!content) return false; + + if ( + action.title === 'Replace selection' && + (!this._currentTextSelection || + this._currentTextSelection.from.length === 0) && + this._currentBlockSelections?.length === 0 + ) { + return false; + } + return true; + }), + action => action.title, + action => { + return html`
+ ${action.icon} +
{ + if ( + action.title === 'Insert below' && + this._selectionValue.length === 1 && + this._selectionValue[0].type === 'database' + ) { + const element = this.host.view.getBlock( + this._selectionValue[0].blockId + ); + if (!element) return; + await insertBelow(host, content, element); + return; + } + + await action.handler( + host, + content, + this._currentTextSelection, + this._currentBlockSelections, + this._currentImageSelections + ); + }} + > + ${action.title} +
+
`; + } + )} +
` + : nothing} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-panel-messages': ChatPanelMessages; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/images.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/images.ts new file mode 100644 index 0000000000..67117233bc --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/components/images.ts @@ -0,0 +1,41 @@ +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +export const renderImages = (images: string[]) => { + return html` +
+ ${repeat( + images, + image => image, + image => { + return html`
+ +
`; + } + )} +
`; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts new file mode 100644 index 0000000000..bd5c350b37 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/const.ts @@ -0,0 +1,10 @@ +export const HISTORY_IMAGE_ACTIONS = [ + 'image', + 'AI image filter clay style', + 'AI image filter sketch style', + 'AI image filter anime style', + 'AI image filter pixel style', + 'Clearer', + 'Remove background', + 'Convert to sticker', +]; 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 new file mode 100644 index 0000000000..a3c529705c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts @@ -0,0 +1,278 @@ +import './chat-panel-input.js'; +import './chat-panel-messages.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { debounce } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { css, html, type PropertyValues } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { createRef, type Ref, ref } from 'lit/directives/ref.js'; + +import { AIHelpIcon, SmallHintIcon } from '../_common/icons.js'; +import { AIProvider } from '../provider.js'; +import { + getSelectedImagesAsBlobs, + getSelectedTextContent, +} from '../utils/selection-utils.js'; +import type { ChatAction, ChatContextValue, ChatItem } from './chat-context.js'; +import type { ChatPanelMessages } from './chat-panel-messages.js'; + +@customElement('chat-panel') +export class ChatPanel extends WithDisposable(ShadowlessElement) { + static override styles = css` + chat-panel { + width: 100%; + } + + .chat-panel-container { + display: flex; + flex-direction: column; + padding: 0 12px; + height: 100%; + } + + .chat-panel-title { + padding: 8px 0px; + width: 100%; + height: 36px; + display: flex; + justify-content: space-between; + align-items: center; + + div:first-child { + font-size: 14px; + font-weight: 500; + color: var(--affine-text-secondary-color); + } + + div:last-child { + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + } + + chat-panel-messages { + flex: 1; + overflow-y: hidden; + } + + .chat-panel-hints { + margin: 0 4px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + font-size: 14px; + font-weight: 500; + cursor: pointer; + } + + .chat-panel-hints :first-child { + color: var(--affine-text-primary-color); + } + + .chat-panel-hints :nth-child(2) { + color: var(--affine-text-secondary-color); + } + + .chat-panel-footer { + margin: 8px 0px; + height: 20px; + display: flex; + gap: 4px; + align-items: center; + color: var(--affine-text-secondary-color); + font-size: 12px; + } + `; + + private readonly _chatMessages: Ref = + createRef(); + + private _chatSessionId = ''; + + private _resettingCounter = 0; + + private readonly _resetItems = debounce(() => { + const counter = ++this._resettingCounter; + this.isLoading = true; + (async () => { + const { doc } = this; + + const [histories, actions] = await Promise.all([ + AIProvider.histories?.chats(doc.collection.id, doc.id), + AIProvider.histories?.actions(doc.collection.id, doc.id), + ]); + + if (counter !== this._resettingCounter) return; + + const items: ChatItem[] = actions ? [...actions] : []; + + if (histories?.[0]) { + this._chatSessionId = histories[0].sessionId; + items.push(...histories[0].messages); + } + + this.chatContextValue = { + ...this.chatContextValue, + items: items.sort((a, b) => { + return ( + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + }), + }; + + this.isLoading = false; + this.scrollToDown(); + })().catch(console.error); + }, 200); + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor doc!: Doc; + + @state() + accessor isLoading = false; + + @state() + accessor chatContextValue: ChatContextValue = { + quote: '', + images: [], + abortController: null, + items: [], + status: 'idle', + error: null, + markdown: '', + }; + + private readonly _cleanupHistories = async () => { + const notification = + this.host.std.spec.getService('affine:page').notificationService; + if (!notification) return; + + if ( + await notification.confirm({ + title: 'Clear History', + message: + 'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.', + confirmText: 'Confirm', + cancelText: 'Cancel', + }) + ) { + await AIProvider.histories?.cleanup(this.doc.collection.id, this.doc.id, [ + this._chatSessionId, + ...( + this.chatContextValue.items.filter( + item => 'sessionId' in item + ) as ChatAction[] + ).map(item => item.sessionId), + ]); + notification.toast('History cleared'); + this._resetItems(); + } + }; + + protected override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('doc')) { + this._resetItems(); + } + } + + override connectedCallback() { + super.connectedCallback(); + if (!this.doc) throw new Error('doc is required'); + + AIProvider.slots.actions.on(({ action, event }) => { + const { status } = this.chatContextValue; + if ( + action !== 'chat' && + event === 'finished' && + (status === 'idle' || status === 'success') + ) { + this._resetItems(); + } + }); + + AIProvider.slots.userInfo.on(userInfo => { + if (userInfo) { + this._resetItems(); + } + }); + + AIProvider.slots.requestContinueInChat.on(async ({ show }) => { + if (show) { + const text = await getSelectedTextContent(this.host, 'plain-text'); + const markdown = await getSelectedTextContent(this.host, 'markdown'); + const images = await getSelectedImagesAsBlobs(this.host); + this.updateContext({ + quote: text, + markdown: markdown, + images: images, + }); + } + }); + } + + updateContext = (context: Partial) => { + this.chatContextValue = { ...this.chatContextValue, ...context }; + }; + + continueInChat = async () => { + const text = await getSelectedTextContent(this.host, 'plain-text'); + const markdown = await getSelectedTextContent(this.host, 'markdown'); + const images = await getSelectedImagesAsBlobs(this.host); + this.updateContext({ + quote: text, + markdown, + images, + }); + }; + + scrollToDown() { + requestAnimationFrame(() => this._chatMessages.value?.scrollToDown()); + } + + override render() { + return html`
+
+
AFFINE AI
+
{ + AIProvider.toggleGeneralAIOnboarding?.(true); + }} + > + ${AIHelpIcon} +
+
+ + + +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-panel': ChatPanel; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/utils.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/utils.ts new file mode 100644 index 0000000000..ed8b30df4b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/utils.ts @@ -0,0 +1,84 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { EdgelessRootService } from '@blocksuite/blocks'; +import type { BlockSnapshot } from '@blocksuite/store'; + +import { markdownToSnapshot } from '../_common/markdown-utils.js'; +import { getSurfaceElementFromEditor } from '../_common/selection-utils.js'; +import { basicTheme } from '../slides/template.js'; + +type PPTSection = { + title: string; + content: string; + keywords: string; +}; + +type PPTDoc = { + isCover: boolean; + title: string; + sections: PPTSection[]; +}; + +export const PPTBuilder = (host: EditorHost) => { + const service = host.spec.getService('affine:page'); + const docs: PPTDoc[] = []; + let done = false; + const addDoc = async (block: BlockSnapshot) => { + const sections = block.children.map(v => { + const title = getText(v); + const keywords = getText(v.children[0]); + const content = getText(v.children[1]); + return { + title, + keywords, + content, + } satisfies PPTSection; + }); + const doc: PPTDoc = { + isCover: docs.length === 0, + title: getText(block), + sections, + }; + docs.push(doc); + + if (doc.sections.length !== 3 || doc.isCover) return; + if (done) return; + done = true; + const job = service.createTemplateJob('template'); + const { images, content } = await basicTheme(doc); + + if (images.length) { + await Promise.all( + images.map(({ id, url }) => + fetch(url) + .then(res => res.blob()) + .then(blob => job.job.assets.set(id, blob)) + ) + ); + } + await job.insertTemplate(content); + getSurfaceElementFromEditor(host).refresh(); + }; + + return { + process: async (text: string) => { + const snapshot = await markdownToSnapshot(text, host); + + const block = snapshot.snapshot.content[0]; + for (const child of block.children) { + await addDoc(child); + const { centerX, centerY, zoom } = service.getFitToScreenData(); + service.viewport.setViewport(zoom, [centerX, centerY]); + } + }, + done: async (text: string) => { + const snapshot = await markdownToSnapshot(text, host); + const block = snapshot.snapshot.content[0]; + await addDoc(block.children[block.children.length - 1]); + }, + }; +}; + +const getText = (block: BlockSnapshot) => { + // @ts-expect-error allow + return block.props.text?.delta?.[0]?.insert ?? ''; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/code-toolbar/setup-code-toolbar.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/code-toolbar/setup-code-toolbar.ts new file mode 100644 index 0000000000..6e1cc1fdea --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/code-toolbar/setup-code-toolbar.ts @@ -0,0 +1,55 @@ +import '../../_common/components/ask-ai-button.js'; + +import type { + AffineCodeToolbarWidget, + CodeBlockComponent, +} from '@blocksuite/blocks'; +import { html } from 'lit'; + +const AICodeItemGroups = buildAICodeItemGroups(); +const buttonOptions: AskAIButtonOptions = { + size: 'small', + panelWidth: 240, +}; + +import type { AskAIButtonOptions } from '../../_common/components/ask-ai-button.js'; +import { buildAICodeItemGroups } from '../../_common/config.js'; +import { AIStarIcon } from '../../_common/icons.js'; + +export function setupCodeToolbarEntry(codeToolbar: AffineCodeToolbarWidget) { + const onAskAIClick = () => { + const { host } = codeToolbar; + const { selection } = host; + const imageBlock = codeToolbar.blockElement; + selection.setGroup('note', [ + selection.create('block', { blockId: imageBlock.blockId }), + ]); + }; + codeToolbar.setupDefaultConfig(); + codeToolbar.addItems( + [ + { + type: 'custom', + name: 'Ask AI', + tooltip: 'Ask AI', + icon: AIStarIcon, + showWhen: () => true, + render(codeBlock: CodeBlockComponent, onClick?: () => void) { + return html` { + e.stopPropagation(); + onAskAIClick(); + onClick?.(); + }} + >`; + }, + }, + ], + 0 + ); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts new file mode 100644 index 0000000000..bf61d403b4 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/actions-config.ts @@ -0,0 +1,513 @@ +import { + type AIItemGroupConfig, + AIStarIconWithAnimation, + BlocksUtils, + MindmapElementModel, + ShapeElementModel, + TextElementModel, +} from '@blocksuite/blocks'; + +import { + AIExpandMindMapIcon, + AIImageIcon, + AIImageIconWithAnimation, + AIMindMapIcon, + AIMindMapIconWithAnimation, + AIPenIcon, + AIPenIconWithAnimation, + AIPresentationIcon, + AIPresentationIconWithAnimation, + AISearchIcon, + ChatWithAIIcon, + ExplainIcon, + ImproveWritingIcon, + LanguageIcon, + LongerIcon, + MakeItRealIcon, + MakeItRealIconWithAnimation, + SelectionIcon, + ShorterIcon, + ToneIcon, +} from '../../_common/icons.js'; +import { + actionToHandler, + experimentalImageActionsShowWhen, + imageOnlyShowWhen, + mindmapChildShowWhen, + mindmapRootShowWhen, + noteBlockOrTextShowWhen, + noteWithCodeBlockShowWen, +} from '../../actions/edgeless-handler.js'; +import { + imageFilterStyles, + imageProcessingTypes, + textTones, + translateLangs, +} from '../../actions/types.js'; +import { getAIPanel } from '../../ai-panel.js'; +import { AIProvider } from '../../provider.js'; +import { mindMapToMarkdown } from '../../utils/edgeless.js'; +import { canvasToBlob, randomSeed } from '../../utils/image.js'; +import { + getCopilotSelectedElems, + getEdgelessRootFromEditor, + imageCustomInput, +} from '../../utils/selection-utils.js'; + +const translateSubItem = translateLangs.map(lang => { + return { + type: lang, + handler: actionToHandler('translate', AIStarIconWithAnimation, { lang }), + }; +}); + +const toneSubItem = textTones.map(tone => { + return { + type: tone, + handler: actionToHandler('changeTone', AIStarIconWithAnimation, { tone }), + }; +}); + +export const imageFilterSubItem = imageFilterStyles.map(style => { + return { + type: style, + handler: actionToHandler( + 'filterImage', + AIImageIconWithAnimation, + { + style, + }, + imageCustomInput + ), + }; +}); + +export const imageProcessingSubItem = imageProcessingTypes.map(type => { + return { + type, + handler: actionToHandler( + 'processImage', + AIImageIconWithAnimation, + { + type, + }, + imageCustomInput + ), + }; +}); + +const othersGroup: AIItemGroupConfig = { + name: 'others', + items: [ + { + name: 'Open AI Chat', + icon: ChatWithAIIcon, + showWhen: () => true, + handler: host => { + const panel = getAIPanel(host); + AIProvider.slots.requestContinueInChat.emit({ + host: host, + show: true, + }); + panel.hide(); + }, + }, + ], +}; + +const editGroup: AIItemGroupConfig = { + name: 'edit with ai', + items: [ + { + name: 'Translate to', + icon: LanguageIcon, + showWhen: noteBlockOrTextShowWhen, + subItem: translateSubItem, + }, + { + name: 'Change tone to', + icon: ToneIcon, + showWhen: noteBlockOrTextShowWhen, + subItem: toneSubItem, + }, + { + name: 'Improve writing', + icon: ImproveWritingIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('improveWriting', AIStarIconWithAnimation), + }, + + { + name: 'Make it longer', + icon: LongerIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('makeLonger', AIStarIconWithAnimation), + }, + { + name: 'Make it shorter', + icon: ShorterIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('makeShorter', AIStarIconWithAnimation), + }, + { + name: 'Continue writing', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('continueWriting', AIPenIconWithAnimation), + }, + ], +}; + +const draftGroup: AIItemGroupConfig = { + name: 'draft with ai', + items: [ + { + name: 'Write an article about this', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('writeArticle', AIPenIconWithAnimation), + }, + { + name: 'Write a tweet about this', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('writeTwitterPost', AIPenIconWithAnimation), + }, + { + name: 'Write a poem about this', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('writePoem', AIPenIconWithAnimation), + }, + { + name: 'Write a blog post about this', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('writeBlogPost', AIPenIconWithAnimation), + }, + { + name: 'Brainstorm ideas about this', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('brainstorm', AIPenIconWithAnimation), + }, + ], +}; + +const reviewGroup: AIItemGroupConfig = { + name: 'review with ai', + items: [ + { + name: 'Fix spelling', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('fixSpelling', AIStarIconWithAnimation), + }, + { + name: 'Fix grammar', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('improveGrammar', AIStarIconWithAnimation), + }, + { + name: 'Explain this image', + icon: AIPenIcon, + showWhen: imageOnlyShowWhen, + handler: actionToHandler( + 'explainImage', + AIStarIconWithAnimation, + undefined, + imageCustomInput + ), + }, + { + name: 'Explain this code', + icon: ExplainIcon, + showWhen: noteWithCodeBlockShowWen, + handler: actionToHandler('explainCode', AIStarIconWithAnimation), + }, + { + name: 'Check code error', + icon: ExplainIcon, + showWhen: noteWithCodeBlockShowWen, + handler: actionToHandler('checkCodeErrors', AIStarIconWithAnimation), + }, + { + name: 'Explain selection', + icon: SelectionIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('explain', AIStarIconWithAnimation), + }, + ], +}; + +const generateGroup: AIItemGroupConfig = { + name: 'generate with ai', + items: [ + { + name: 'Summarize', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('summary', AIPenIconWithAnimation), + }, + { + name: 'Generate headings', + icon: AIPenIcon, + handler: actionToHandler('createHeadings', AIPenIconWithAnimation), + showWhen: noteBlockOrTextShowWhen, + beta: true, + }, + { + name: 'Generate an image', + icon: AIImageIcon, + showWhen: () => true, + handler: actionToHandler( + 'createImage', + AIImageIconWithAnimation, + undefined, + async (host, ctx) => { + const selectedElements = getCopilotSelectedElems(host); + const len = selectedElements.length; + + const aiPanel = getAIPanel(host); + // text to image + // from user input + if (len === 0) { + const content = aiPanel.inputText?.trim(); + if (!content) return; + return { + content, + }; + } + + let content = (ctx.get()['content'] as string) || ''; + + // from user input + if (content.length === 0) { + content = aiPanel.inputText?.trim() || ''; + } + + const { + images, + shapes, + notes: _, + frames: __, + } = BlocksUtils.splitElements(selectedElements); + + const pureShapes = shapes.filter( + e => + !( + e instanceof TextElementModel || + (e instanceof ShapeElementModel && e.text?.length) + ) + ); + + // text to image + if (content.length && images.length + pureShapes.length === 0) { + return { + content, + }; + } + + // image to image + const edgelessRoot = getEdgelessRootFromEditor(host); + const canvas = await edgelessRoot.clipboardController.toCanvas( + images, + pureShapes, + { + dpr: 1, + padding: 0, + background: 'white', + } + ); + if (!canvas) return; + + const png = await canvasToBlob(canvas); + if (!png) return; + return { + content, + attachments: [png], + seed: String(randomSeed()), + }; + } + ), + }, + { + name: 'Generate outline', + icon: AIPenIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('writeOutline', AIPenIconWithAnimation), + }, + { + name: 'Expand from this mind map node', + icon: AIExpandMindMapIcon, + showWhen: mindmapChildShowWhen, + handler: actionToHandler( + 'expandMindmap', + AIMindMapIconWithAnimation, + undefined, + function (host) { + const selected = getCopilotSelectedElems(host); + const firstSelected = selected[0] as ShapeElementModel; + const mindmap = firstSelected?.group; + + if (!(mindmap instanceof MindmapElementModel)) { + return Promise.resolve({}); + } + + return Promise.resolve({ + input: firstSelected.text?.toString() ?? '', + mindmap: mindMapToMarkdown(mindmap), + }); + } + ), + beta: true, + }, + { + name: 'Brainstorm ideas with mind map', + icon: AIMindMapIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('brainstormMindmap', AIMindMapIconWithAnimation), + }, + { + name: 'Regenerate mind map', + icon: AIMindMapIcon, + showWhen: mindmapRootShowWhen, + handler: actionToHandler( + 'brainstormMindmap', + AIMindMapIconWithAnimation, + { + regenerate: true, + } + ), + }, + { + name: 'Generate presentation', + icon: AIPresentationIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('createSlides', AIPresentationIconWithAnimation), + beta: true, + }, + { + name: 'Make it real', + icon: MakeItRealIcon, + beta: true, + showWhen: () => true, + handler: actionToHandler( + 'makeItReal', + MakeItRealIconWithAnimation, + undefined, + async (host, ctx) => { + const selectedElements = getCopilotSelectedElems(host); + + // from user input + if (selectedElements.length === 0) { + const aiPanel = getAIPanel(host); + const content = aiPanel.inputText?.trim(); + if (!content) return; + return { + content, + }; + } + + const { notes, frames, shapes, images, edgelessTexts } = + BlocksUtils.splitElements(selectedElements); + const f = frames.length; + const i = images.length; + const n = notes.length; + const s = shapes.length; + const e = edgelessTexts.length; + + if (f + i + n + s + e === 0) { + return; + } + let content = (ctx.get()['content'] as string) || ''; + + // single note, text + if ( + i === 0 && + n + s + e === 1 && + (n === 1 || + e === 1 || + (s === 1 && shapes[0] instanceof TextElementModel)) + ) { + return { + content, + }; + } + + // from user input + if (content.length === 0) { + const aiPanel = getAIPanel(host); + content = aiPanel.inputText?.trim() || ''; + } + + const edgelessRoot = getEdgelessRootFromEditor(host); + const canvas = await edgelessRoot.clipboardController.toCanvas( + [...notes, ...frames, ...images], + shapes, + { + dpr: 1, + background: 'white', + } + ); + if (!canvas) return; + const png = await canvasToBlob(canvas); + if (!png) return; + ctx.set({ + width: canvas.width, + height: canvas.height, + }); + + return { + content, + attachments: [png], + }; + } + ), + }, + { + name: 'AI image filter', + icon: ImproveWritingIcon, + showWhen: experimentalImageActionsShowWhen, + subItem: imageFilterSubItem, + subItemOffset: [12, -4], + beta: true, + }, + { + name: 'Image processing', + icon: AIImageIcon, + showWhen: experimentalImageActionsShowWhen, + subItem: imageProcessingSubItem, + subItemOffset: [12, -6], + beta: true, + }, + { + name: 'Generate a caption', + icon: AIPenIcon, + showWhen: experimentalImageActionsShowWhen, + beta: true, + handler: actionToHandler( + 'generateCaption', + AIStarIconWithAnimation, + undefined, + imageCustomInput + ), + }, + { + name: 'Find actions', + icon: AISearchIcon, + showWhen: noteBlockOrTextShowWhen, + handler: actionToHandler('findActions', AIStarIconWithAnimation), + beta: true, + }, + ], +}; + +export const edgelessActionGroups = [ + reviewGroup, + editGroup, + generateGroup, + draftGroup, + othersGroup, +]; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/index.ts new file mode 100644 index 0000000000..d312336ea2 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/edgeless/index.ts @@ -0,0 +1,47 @@ +import type { + AIItemGroupConfig, + EdgelessCopilotWidget, + EdgelessElementToolbarWidget, + EdgelessRootBlockComponent, +} from '@blocksuite/blocks'; +import { EdgelessCopilotToolbarEntry } from '@blocksuite/blocks'; +import { noop } from '@blocksuite/global/utils'; +import { html } from 'lit'; + +import { edgelessActionGroups } from './actions-config.js'; + +noop(EdgelessCopilotToolbarEntry); + +export function setupEdgelessCopilot(widget: EdgelessCopilotWidget) { + widget.groups = edgelessActionGroups; +} + +export function setupEdgelessElementToolbarEntry( + widget: EdgelessElementToolbarWidget +) { + widget.registerEntry({ + when: () => { + return true; + }, + render: (edgeless: EdgelessRootBlockComponent) => { + const chain = edgeless.service.std.command.chain(); + const filteredGroups = edgelessActionGroups.reduce((pre, group) => { + const filtered = group.items.filter(item => + item.showWhen?.(chain, 'edgeless', edgeless.host) + ); + + if (filtered.length > 0) pre.push({ ...group, items: filtered }); + + return pre; + }, [] as AIItemGroupConfig[]); + + if (filteredGroups.every(group => group.items.length === 0)) return null; + + return html``; + }, + }); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts new file mode 100644 index 0000000000..953829cca2 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts @@ -0,0 +1,29 @@ +import '../../_common/components/ask-ai-button.js'; + +import { + type AffineFormatBarWidget, + toolbarDefaultConfig, +} from '@blocksuite/blocks'; +import { html, type TemplateResult } from 'lit'; + +import { AIItemGroups } from '../../_common/config.js'; + +export function setupFormatBarEntry(formatBar: AffineFormatBarWidget) { + toolbarDefaultConfig(formatBar); + formatBar.addRawConfigItems( + [ + { + type: 'custom' as const, + render(formatBar: AffineFormatBarWidget): TemplateResult | null { + return html` `; + }, + }, + { type: 'divider' }, + ], + 0 + ); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/image-toolbar/setup-image-toolbar.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/image-toolbar/setup-image-toolbar.ts new file mode 100644 index 0000000000..1aa32d6db3 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/image-toolbar/setup-image-toolbar.ts @@ -0,0 +1,52 @@ +import '../../_common/components/ask-ai-button.js'; + +import type { + AffineImageToolbarWidget, + ImageBlockComponent, +} from '@blocksuite/blocks'; +import { html } from 'lit'; + +import type { AskAIButtonOptions } from '../../_common/components/ask-ai-button.js'; +import { buildAIImageItemGroups } from '../../_common/config.js'; + +const AIImageItemGroups = buildAIImageItemGroups(); +const buttonOptions: AskAIButtonOptions = { + size: 'small', + backgroundColor: 'var(--affine-white)', + panelWidth: 300, +}; + +export function setupImageToolbarEntry(imageToolbar: AffineImageToolbarWidget) { + const onAskAIClick = () => { + const { host } = imageToolbar; + const { selection } = host; + const imageBlock = imageToolbar.blockElement; + selection.setGroup('note', [ + selection.create('image', { blockId: imageBlock.blockId }), + ]); + }; + imageToolbar.buildDefaultConfig(); + imageToolbar.addConfigItems( + [ + { + type: 'custom', + render(imageBlock: ImageBlockComponent, onClick?: () => void) { + return html` { + e.stopPropagation(); + onAskAIClick(); + onClick?.(); + }} + >`; + }, + showWhen: () => true, + }, + ], + 0 + ); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/index.ts new file mode 100644 index 0000000000..a3c78cc140 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/index.ts @@ -0,0 +1,2 @@ +export * from './format-bar/setup-format-bar.js'; +export * from './space/setup-space.js'; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/slash-menu/setup-slash-menu.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/slash-menu/setup-slash-menu.ts new file mode 100644 index 0000000000..0c6b510c0a --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/slash-menu/setup-slash-menu.ts @@ -0,0 +1,138 @@ +import type { + AffineAIPanelWidget, + AffineSlashMenuActionItem, + AffineSlashMenuContext, + AffineSlashMenuItem, + AffineSlashSubMenu, + AIItemConfig, +} from '@blocksuite/blocks'; +import { + AFFINE_AI_PANEL_WIDGET, + AffineSlashMenuWidget, + AIStarIcon, + MoreHorizontalIcon, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { html } from 'lit'; + +import { AIItemGroups } from '../../_common/config.js'; +import { handleInlineAskAIAction } from '../../actions/doc-handler.js'; +import { AIProvider } from '../../provider.js'; + +export function setupSlashMenuEntry(slashMenu: AffineSlashMenuWidget) { + const AIItems = AIItemGroups.map(group => group.items).flat(); + + const iconWrapper = (icon: AIItemConfig['icon']) => { + return html`
+ ${typeof icon === 'function' ? html`${icon()}` : icon} +
`; + }; + + const showWhenWrapper = + (item?: AIItemConfig) => + ({ rootElement }: AffineSlashMenuContext) => { + const affineAIPanelWidget = rootElement.host.view.getWidget( + AFFINE_AI_PANEL_WIDGET, + rootElement.model.id + ); + if (affineAIPanelWidget === null) return false; + + const chain = rootElement.host.command.chain(); + const editorMode = rootElement.service.docModeService.getMode( + rootElement.doc.id + ); + + return item?.showWhen?.(chain, editorMode, rootElement.host) ?? true; + }; + + const actionItemWrapper = ( + item: AIItemConfig + ): AffineSlashMenuActionItem => ({ + ...basicItemConfig(item), + action: ({ rootElement }: AffineSlashMenuContext) => { + item?.handler?.(rootElement.host); + }, + }); + + const subMenuWrapper = (item: AIItemConfig): AffineSlashSubMenu => { + assertExists(item.subItem); + return { + ...basicItemConfig(item), + subMenu: item.subItem.map( + ({ type, handler }) => ({ + name: type, + action: ({ rootElement }) => handler?.(rootElement.host), + }) + ), + }; + }; + + const basicItemConfig = (item: AIItemConfig) => { + return { + name: item.name, + icon: iconWrapper(item.icon), + alias: ['ai'], + showWhen: showWhenWrapper(item), + }; + }; + + const AIMenuItems: AffineSlashMenuItem[] = [ + { groupName: 'AFFiNE AI' }, + { + name: 'Ask AI', + icon: AIStarIcon, + showWhen: showWhenWrapper(), + action: ({ rootElement }) => { + const view = rootElement.host.view; + const affineAIPanelWidget = view.getWidget( + AFFINE_AI_PANEL_WIDGET, + rootElement.model.id + ) as AffineAIPanelWidget; + assertExists(affineAIPanelWidget); + assertExists(AIProvider.actions.chat); + assertExists(affineAIPanelWidget.host); + handleInlineAskAIAction(affineAIPanelWidget.host); + }, + }, + + ...AIItems.filter(({ name }) => + ['Fix spelling', 'Fix grammar'].includes(name) + ).map(item => ({ + ...actionItemWrapper(item), + name: `${item.name} from above`, + })), + + ...AIItems.filter(({ name }) => + ['Summarize', 'Continue writing'].includes(name) + ).map(actionItemWrapper), + + { + name: 'Action with above', + icon: iconWrapper(MoreHorizontalIcon), + subMenu: [ + { groupName: 'Action with above' }, + ...AIItems.filter(({ name }) => + ['Translate to', 'Change tone to'].includes(name) + ).map(subMenuWrapper), + + ...AIItems.filter(({ name }) => + [ + 'Improve writing', + 'Make it longer', + 'Make it shorter', + 'Generate outline', + 'Find actions', + ].includes(name) + ).map(actionItemWrapper), + ], + }, + ]; + + const menu = slashMenu.config.items.slice(); + menu.unshift(...AIMenuItems); + + slashMenu.config = { + ...AffineSlashMenuWidget.DEFAULT_CONFIG, + items: menu, + }; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/space/setup-space.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/space/setup-space.ts new file mode 100644 index 0000000000..5416983bfe --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/space/setup-space.ts @@ -0,0 +1,25 @@ +import type { AffineAIPanelWidget } from '@blocksuite/blocks'; + +import { handleInlineAskAIAction } from '../../actions/doc-handler.js'; +import { AIProvider } from '../../provider.js'; + +export function setupSpaceEntry(panel: AffineAIPanelWidget) { + panel.handleEvent('keyDown', ctx => { + const host = panel.host; + const keyboardState = ctx.get('keyboardState'); + if ( + AIProvider.actions.chat && + keyboardState.raw.key === ' ' && + !keyboardState.raw.isComposing + ) { + const selection = host.selection.find('text'); + if (selection && selection.isCollapsed() && selection.from.index === 0) { + const block = host.view.getBlock(selection.blockId); + if (!block?.model?.text || block.model.text?.length > 0) return; + + keyboardState.raw.preventDefault(); + handleInlineAskAIAction(host); + } + } + }); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/index.ts new file mode 100644 index 0000000000..831844785f --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/index.ts @@ -0,0 +1,7 @@ +export * from './actions/index.js'; +export * from './ai-spec.js'; +export { ChatPanel } from './chat-panel/index.js'; +export * from './entries/edgeless/actions-config.js'; +export * from './entries/index.js'; +export * from './messages/index.js'; +export * from './provider.js'; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts new file mode 100644 index 0000000000..179b6c7058 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/error.ts @@ -0,0 +1,114 @@ +import { type EditorHost, WithDisposable } from '@blocksuite/block-std'; +import { html, LitElement, nothing, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { ErrorTipIcon } from '../_common/icons.js'; +import { AIProvider } from '../provider.js'; + +@customElement('ai-error-wrapper') +class AIErrorWrapper extends WithDisposable(LitElement) { + @property({ attribute: false }) + accessor text!: TemplateResult<1>; + + protected override render() { + return html` +
+
+
${ErrorTipIcon}
+
${this.text}
+
+ +
`; + } +} + +export const PaymentRequiredErrorRenderer = (host: EditorHost) => html` + + +
AIProvider.slots.requestUpgradePlan.emit({ host: host })} + class="upgrade" + > +
Upgrade
+
+`; + +export const GeneralErrorRenderer = ( + text: TemplateResult<1> = html`An error occurred, If this issue persists + please let us know. + support@toeverything.info `, + template: TemplateResult<1> = html`${nothing}` +) => html` ${template}`; + +declare global { + interface HTMLElementTagNameMap { + 'ai-error-wrapper': AIErrorWrapper; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/index.ts new file mode 100644 index 0000000000..18b83aab05 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/index.ts @@ -0,0 +1,2 @@ +export * from './text.js'; +export * from './wrapper.js'; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/mindmap.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/mindmap.ts new file mode 100644 index 0000000000..f93b05b9c1 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/mindmap.ts @@ -0,0 +1,79 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { + AffineAIPanelWidgetConfig, + MindmapStyle, +} from '@blocksuite/blocks'; +import { markdownToMindmap, MiniMindmapPreview } from '@blocksuite/blocks'; +import { noop } from '@blocksuite/global/utils'; +import { html, nothing } from 'lit'; + +import { getAIPanel } from '../ai-panel.js'; + +noop(MiniMindmapPreview); + +export const createMindmapRenderer: ( + host: EditorHost, + /** + * Used to store data for later use during rendering. + */ + ctx: { + get: () => Record; + set: (data: Record) => void; + }, + style?: MindmapStyle +) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, ctx, style) => { + return (answer, state) => { + if (state === 'generating') { + const panel = getAIPanel(host); + panel.generatingElement?.updateLoadingProgress(2); + } + + if (state !== 'finished' && state !== 'error') { + return nothing; + } + + return html``; + }; +}; + +/** + * Creates a renderer for executing a handler. + * The ai panel will not display anything after the answer is generated. + */ +export const createMindmapExecuteRenderer: ( + host: EditorHost, + /** + * Used to store data for later use during rendering. + */ + ctx: { + get: () => Record; + set: (data: Record) => void; + }, + handler: (ctx: { + get: () => Record; + set: (data: Record) => void; + }) => void +) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, ctx, handler) => { + return (answer, state) => { + if (state !== 'finished') { + const panel = getAIPanel(host); + panel.generatingElement?.updateLoadingProgress(2); + return nothing; + } + + ctx.set({ + node: markdownToMindmap(answer, host.doc), + }); + + handler(ctx); + + return nothing; + }; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts new file mode 100644 index 0000000000..585030f954 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/slides-renderer.ts @@ -0,0 +1,234 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/block-std'; +import { + type AffineAIPanelWidgetConfig, + EdgelessEditorBlockSpecs, +} from '@blocksuite/blocks'; +import { AffineSchemas } from '@blocksuite/blocks/schemas'; +import type { Doc } from '@blocksuite/store'; +import { DocCollection, Schema } from '@blocksuite/store'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { createRef, type Ref, ref } from 'lit/directives/ref.js'; + +import { getAIPanel } from '../ai-panel.js'; +import { PPTBuilder } from '../slides/index.js'; + +export const createSlidesRenderer: ( + host: EditorHost, + ctx: { + get: () => Record; + set: (data: Record) => void; + } +) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, ctx) => { + return (answer, state) => { + if (state === 'generating') { + const panel = getAIPanel(host); + panel.generatingElement?.updateLoadingProgress(2); + return nothing; + } + + if (state !== 'finished' && state !== 'error') { + return nothing; + } + + return html` +
+ +
`; + }; +}; + +@customElement('ai-slides-renderer') +export class AISlidesRenderer extends WithDisposable(LitElement) { + static override styles = css``; + + private readonly _editorContainer: Ref = + createRef(); + + private _doc!: Doc; + + @query('editor-host') + private accessor _editorHost!: EditorHost; + + @property({ attribute: false }) + accessor text!: string; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor ctx: + | { + get(): Record; + set(data: Record): void; + } + | undefined = undefined; + + protected override firstUpdated() { + requestAnimationFrame(() => { + if (!this._editorHost) return; + PPTBuilder(this._editorHost) + .process(this.text) + .then(res => { + if (this.ctx) { + this.ctx.set({ + contents: res.contents, + images: res.images, + }); + } + }) + .catch(console.error); + }); + } + + protected override render() { + return html` +
+
+ ${this.host.renderSpecPortal(this._doc, EdgelessEditorBlockSpecs)} +
+
+
`; + } + + override connectedCallback(): void { + super.connectedCallback(); + + const schema = new Schema().register(AffineSchemas); + const collection = new DocCollection({ schema, id: 'SLIDES_PREVIEW' }); + collection.start(); + const doc = collection.createDoc(); + + doc.load(() => { + const pageBlockId = doc.addBlock('affine:page', {}); + doc.addBlock('affine:surface', {}, pageBlockId); + }); + + doc.resetHistory(); + this._doc = doc; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-slides-renderer': AISlidesRenderer; + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/text.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/text.ts new file mode 100644 index 0000000000..e3e8b0868b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/text.ts @@ -0,0 +1,298 @@ +import { type EditorHost, WithDisposable } from '@blocksuite/block-std'; +import type { + AffineAIPanelState, + AffineAIPanelWidgetConfig, +} from '@blocksuite/blocks'; +import { + BlocksUtils, + CodeBlockComponent, + DividerBlockComponent, + ListBlockComponent, + ParagraphBlockComponent, +} from '@blocksuite/blocks'; +import { type BlockSelector, BlockViewType, type Doc } from '@blocksuite/store'; +import { css, html, LitElement, type PropertyValues } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { keyed } from 'lit/directives/keyed.js'; + +import { CustomPageEditorBlockSpecs } from '../utils/custom-specs.js'; +import { markDownToDoc } from '../utils/markdown-utils.js'; + +const textBlockStyles = css` + ${ParagraphBlockComponent.styles} + ${ListBlockComponent.styles} + ${DividerBlockComponent.styles} + ${CodeBlockComponent.styles} +`; + +const customHeadingStyles = css` + .custom-heading { + .h1 { + font-size: calc(var(--affine-font-h-1) - 2px); + code { + font-size: calc(var(--affine-font-base) + 6px); + } + } + .h2 { + font-size: calc(var(--affine-font-h-2) - 2px); + code { + font-size: calc(var(--affine-font-base) + 4px); + } + } + .h3 { + font-size: calc(var(--affine-font-h-3) - 2px); + code { + font-size: calc(var(--affine-font-base) + 2px); + } + } + .h4 { + font-size: calc(var(--affine-font-h-4) - 2px); + code { + font-size: var(--affine-font-base); + } + } + .h5 { + font-size: calc(var(--affine-font-h-5) - 2px); + code { + font-size: calc(var(--affine-font-base) - 2px); + } + } + .h6 { + font-size: calc(var(--affine-font-h-6) - 2px); + code { + font-size: calc(var(--affine-font-base) - 4px); + } + } + } +`; + +type TextRendererOptions = { + maxHeight?: number; + customHeading?: boolean; +}; + +@customElement('ai-answer-text') +export class AIAnswerText extends WithDisposable(LitElement) { + static override styles = css` + .ai-answer-text-editor.affine-page-viewport { + background: transparent; + font-family: var(--affine-font-family); + margin-top: 0; + margin-bottom: 0; + } + + .ai-answer-text-editor .affine-page-root-block-container { + padding: 0; + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + } + + .affine-paragraph-block-container { + line-height: 22px; + } + + .ai-answer-text-editor { + .affine-note-block-container { + > .affine-block-children-container { + > :first-child, + > :first-child * { + margin-top: 0 !important; + } + > :last-child, + > :last-child * { + margin-top: 0 !important; + } + } + } + } + + .ai-answer-text-container { + overflow-y: auto; + overflow-x: hidden; + padding: 0; + overscroll-behavior-y: none; + } + .ai-answer-text-container.show-scrollbar::-webkit-scrollbar { + width: 5px; + height: 100px; + } + .ai-answer-text-container.show-scrollbar::-webkit-scrollbar-thumb { + border-radius: 20px; + } + .ai-answer-text-container.show-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: var(--affine-black-30); + } + .ai-answer-text-container.show-scrollbar::-webkit-scrollbar-corner { + display: none; + } + + .ai-answer-text-container { + rich-text .nowrap-lines v-text span, + rich-text .nowrap-lines v-element span { + white-space: pre; + } + editor-host:focus-visible { + outline: none; + } + editor-host * { + box-sizing: border-box; + } + } + + ${textBlockStyles} + ${customHeadingStyles} + `; + + @query('.ai-answer-text-container') + private accessor _container!: HTMLDivElement; + + private _doc!: Doc; + + private _answers: string[] = []; + + private _timer?: ReturnType | null = null; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor answer!: string; + + @property({ attribute: false }) + accessor options!: TextRendererOptions; + + @property({ attribute: false }) + accessor state: AffineAIPanelState | undefined = undefined; + + private _onWheel(e: MouseEvent) { + e.stopPropagation(); + if (this.state === 'generating') { + e.preventDefault(); + } + } + + private readonly _clearTimer = () => { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + }; + + private readonly _selector: BlockSelector = block => + BlocksUtils.matchFlavours(block.model, [ + 'affine:page', + 'affine:note', + 'affine:surface', + 'affine:paragraph', + 'affine:code', + 'affine:list', + 'affine:divider', + ]) + ? BlockViewType.Display + : BlockViewType.Hidden; + + private readonly _updateDoc = () => { + if (this._answers.length > 0) { + const latestAnswer = this._answers.pop(); + this._answers = []; + if (latestAnswer) { + markDownToDoc(this.host, latestAnswer) + .then(doc => { + this._doc = doc.blockCollection.getDoc({ + selector: this._selector, + }); + this.disposables.add(() => { + doc.blockCollection.clearSelector(this._selector); + }); + this._doc.awarenessStore.setReadonly( + this._doc.blockCollection, + true + ); + this.requestUpdate(); + if (this.state !== 'generating') { + this._clearTimer(); + } + }) + .catch(console.error); + } + } + }; + + override shouldUpdate(changedProperties: PropertyValues) { + if (changedProperties.has('answer')) { + this._answers.push(this.answer); + return false; + } + + return true; + } + + override connectedCallback() { + super.connectedCallback(); + this._answers.push(this.answer); + + this._updateDoc(); + if (this.state === 'generating') { + this._timer = setInterval(this._updateDoc, 600); + } + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._clearTimer(); + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + requestAnimationFrame(() => { + if (!this._container) return; + this._container.scrollTop = this._container.scrollHeight; + }); + } + + override render() { + const { maxHeight, customHeading } = this.options; + const classes = classMap({ + 'ai-answer-text-container': true, + 'show-scrollbar': !!maxHeight, + 'custom-heading': !!customHeading, + }); + return html` + +
+ ${keyed( + this._doc, + html`
+ ${this.host.renderSpecPortal(this._doc, CustomPageEditorBlockSpecs)} +
` + )} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-answer-text': AIAnswerText; + } +} + +export const createTextRenderer: ( + host: EditorHost, + options: TextRendererOptions +) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => { + return (answer, state) => { + return html``; + }; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/messages/wrapper.ts b/packages/frontend/core/src/blocksuite/presets/ai/messages/wrapper.ts new file mode 100644 index 0000000000..3ae585473e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/messages/wrapper.ts @@ -0,0 +1,119 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { AffineAIPanelWidgetConfig } from '@blocksuite/blocks'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { getAIPanel } from '../ai-panel.js'; +import { preprocessHtml } from '../utils/html.js'; + +type AIAnswerWrapperOptions = { + height: number; +}; + +@customElement('ai-answer-wrapper') +export class AIAnswerWrapper extends LitElement { + static override styles = css` + :host { + display: block; + width: 100%; + box-sizing: border-box; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + box-shadow: var(--affine-shadow-1); + background: var(--affine-background-secondary-color); + overflow: hidden; + } + + ::slotted(.ai-answer-iframe) { + width: 100%; + height: 100%; + border: none; + } + + ::slotted(.ai-answer-image) { + width: 100%; + height: 100%; + } + `; + + @property({ attribute: false }) + accessor options: AIAnswerWrapperOptions | undefined = undefined; + + protected override render() { + return html` + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-answer-wrapper': AIAnswerWrapper; + } +} + +export const createIframeRenderer: ( + host: EditorHost, + options?: AIAnswerWrapperOptions +) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => { + return (answer, state) => { + if (state === 'generating') { + const panel = getAIPanel(host); + panel.generatingElement?.updateLoadingProgress(2); + return nothing; + } + + if (state !== 'finished' && state !== 'error') { + return nothing; + } + + const template = html``; + return html`${template}`; + }; +}; + +export const createImageRenderer: ( + host: EditorHost, + options?: AIAnswerWrapperOptions +) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => { + return (answer, state) => { + if (state === 'generating') { + const panel = getAIPanel(host); + panel.generatingElement?.updateLoadingProgress(2); + return nothing; + } + + if (state !== 'finished' && state !== 'error') { + return nothing; + } + + const template = html` +
+ +
`; + + return html`${template}`; + }; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/provider.ts b/packages/frontend/core/src/blocksuite/presets/ai/provider.ts new file mode 100644 index 0000000000..d6b54edd97 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/provider.ts @@ -0,0 +1,252 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { PaymentRequiredError, UnauthorizedError } from '@blocksuite/blocks'; +import { Slot } from '@blocksuite/store'; + +export interface AIUserInfo { + id: string; + email: string; + name: string; + avatarUrl: string | null; +} + +export type ActionEventType = + | 'started' + | 'finished' + | 'error' + | 'aborted:paywall' + | 'aborted:login-required' + | 'aborted:server-error' + | 'aborted:stop' + | 'result:insert' + | 'result:replace' + | 'result:use-as-caption' + | 'result:add-page' + | 'result:add-note' + | 'result:continue-in-chat' + | 'result:discard' + | 'result:retry'; + +/** + * AI provider for the block suite + * + * To use it, downstream (affine) has to provide AI actions implementation, + * user info etc + * + * todo: breakdown into different parts? + */ +export class AIProvider { + static get slots() { + return AIProvider.instance.slots; + } + + static get actions() { + return AIProvider.instance.actions; + } + + static get userInfo() { + return AIProvider.instance.userInfoFn(); + } + + static get photoEngine() { + return AIProvider.instance.photoEngine; + } + + static get histories() { + return AIProvider.instance.histories; + } + + static get actionHistory() { + return AIProvider.instance.actionHistory; + } + + static get toggleGeneralAIOnboarding() { + return AIProvider.instance.toggleGeneralAIOnboarding; + } + + private static readonly instance = new AIProvider(); + + static LAST_ACTION_SESSIONID = ''; + + static MAX_LOCAL_HISTORY = 10; + + private readonly actions: Partial = {}; + + private photoEngine: BlockSuitePresets.AIPhotoEngineService | null = null; + + private histories: BlockSuitePresets.AIHistoryService | null = null; + + private toggleGeneralAIOnboarding: ((value: boolean) => void) | null = null; + + private readonly slots = { + // use case: when user selects "continue in chat" in an ask ai result panel + // do we need to pass the context to the chat panel? + requestContinueInChat: new Slot<{ host: EditorHost; show: boolean }>(), + requestLogin: new Slot<{ host: EditorHost }>(), + requestUpgradePlan: new Slot<{ host: EditorHost }>(), + // when an action is requested to run in edgeless mode (show a toast in affine) + requestRunInEdgeless: new Slot<{ host: EditorHost }>(), + // stream of AI actions triggered by users + actions: new Slot<{ + action: keyof BlockSuitePresets.AIActions; + options: BlockSuitePresets.AITextActionOptions; + event: ActionEventType; + }>(), + // downstream can emit this slot to notify ai presets that user info has been updated + userInfo: new Slot(), + // add more if needed + }; + + // track the history of triggered actions (in memory only) + private readonly actionHistory: { + action: keyof BlockSuitePresets.AIActions; + options: BlockSuitePresets.AITextActionOptions; + }[] = []; + + private userInfoFn: () => AIUserInfo | Promise | null = () => + null; + + private provideAction( + id: T, + action: ( + ...options: Parameters + ) => ReturnType + ): void { + if (this.actions[id]) { + console.warn(`AI action ${id} is already provided`); + } + + // @ts-expect-error todo: maybe fix this + this.actions[id] = ( + ...args: Parameters + ) => { + const options = args[0]; + const slots = this.slots; + slots.actions.emit({ + action: id, + options, + event: 'started', + }); + this.actionHistory.push({ action: id, options }); + if (this.actionHistory.length > AIProvider.MAX_LOCAL_HISTORY) { + this.actionHistory.shift(); + } + // wrap the action with slot actions + const result: BlockSuitePresets.TextStream | Promise = action( + ...args + ); + const isTextStream = ( + m: BlockSuitePresets.TextStream | Promise + ): m is BlockSuitePresets.TextStream => + Reflect.has(m, Symbol.asyncIterator); + if (isTextStream(result)) { + return { + [Symbol.asyncIterator]: async function* () { + try { + yield* result; + slots.actions.emit({ + action: id, + options, + event: 'finished', + }); + } catch (err) { + slots.actions.emit({ + action: id, + options, + event: 'error', + }); + if (err instanceof PaymentRequiredError) { + slots.actions.emit({ + action: id, + options, + event: 'aborted:paywall', + }); + } else if (err instanceof UnauthorizedError) { + slots.actions.emit({ + action: id, + options, + event: 'aborted:login-required', + }); + } else { + slots.actions.emit({ + action: id, + options, + event: 'aborted:server-error', + }); + } + throw err; + } + }, + }; + } else { + return result + .then(result => { + slots.actions.emit({ + action: id, + options, + event: 'finished', + }); + return result; + }) + .catch(err => { + slots.actions.emit({ + action: id, + options, + event: 'error', + }); + if (err instanceof PaymentRequiredError) { + slots.actions.emit({ + action: id, + options, + event: 'aborted:paywall', + }); + } + throw err; + }); + } + }; + } + + static provide( + id: 'userInfo', + fn: () => AIUserInfo | Promise | null + ): void; + + static provide( + id: 'histories', + service: BlockSuitePresets.AIHistoryService + ): void; + + static provide( + id: 'photoEngine', + engine: BlockSuitePresets.AIPhotoEngineService + ): void; + + static provide(id: 'onboarding', fn: (value: boolean) => void): void; + + // actions: + static provide( + id: T, + action: ( + ...options: Parameters + ) => ReturnType + ): void; + + static provide(id: unknown, action: unknown) { + if (id === 'userInfo') { + AIProvider.instance.userInfoFn = action as () => AIUserInfo; + } else if (id === 'histories') { + AIProvider.instance.histories = + action as BlockSuitePresets.AIHistoryService; + } else if (id === 'photoEngine') { + AIProvider.instance.photoEngine = + action as BlockSuitePresets.AIPhotoEngineService; + } else if (id === 'onboarding') { + AIProvider.instance.toggleGeneralAIOnboarding = action as ( + value: boolean + ) => void; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + AIProvider.instance.provideAction(id as any, action as any); + } + } +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/slides/index.ts new file mode 100644 index 0000000000..c4825adf2b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/index.ts @@ -0,0 +1,84 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { EdgelessRootService } from '@blocksuite/blocks'; +import type { BlockSnapshot } from '@blocksuite/store'; + +import { markdownToSnapshot } from '../_common/markdown-utils.js'; +import { getSurfaceElementFromEditor } from '../_common/selection-utils.js'; +import { + basicTheme, + type PPTDoc, + type PPTSection, + type TemplateImage, +} from './template.js'; + +export const PPTBuilder = (host: EditorHost) => { + const service = host.spec.getService('affine:page'); + const docs: PPTDoc[] = []; + const contents: unknown[] = []; + const allImages: TemplateImage[][] = []; + + const addDoc = async (block: BlockSnapshot) => { + const sections = block.children.map(v => { + const title = getText(v); + const keywords = getText(v.children[0]); + const content = getText(v.children[1]); + return { + title, + keywords, + content, + } satisfies PPTSection; + }); + const doc: PPTDoc = { + isCover: docs.length === 0, + title: getText(block), + sections, + }; + docs.push(doc); + + if (doc.isCover) return; + const job = service.createTemplateJob('template'); + const { images, content } = await basicTheme(doc); + contents.push(content); + allImages.push(images); + if (images.length) { + await Promise.all( + images.map(({ id, url }) => + fetch(url) + .then(res => res.blob()) + .then(blob => job.job.assets.set(id, blob)) + ) + ); + } + await job.insertTemplate(content); + getSurfaceElementFromEditor(host).refresh(); + }; + + return { + process: async (text: string) => { + try { + const snapshot = await markdownToSnapshot(text, host); + + const block = snapshot.snapshot.content[0]; + for (const child of block.children) { + await addDoc(child); + const { centerX, centerY, zoom } = service.getFitToScreenData(); + service.viewport.setViewport(zoom, [centerX, centerY]); + } + } catch (e) { + console.error(e); + } + + return { contents, images: allImages }; + }, + done: async (text: string) => { + const snapshot = await markdownToSnapshot(text, host); + const block = snapshot.snapshot.content[0]; + await addDoc(block.children[block.children.length - 1]); + }, + }; +}; + +const getText = (block: BlockSnapshot) => { + // @ts-expect-error allow + return block.props.text?.delta?.[0]?.insert ?? ''; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/template.ts b/packages/frontend/core/src/blocksuite/presets/ai/slides/template.ts new file mode 100644 index 0000000000..05af834ec0 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/template.ts @@ -0,0 +1,321 @@ +import { Bound } from '@blocksuite/blocks'; +import { nanoid } from '@blocksuite/store'; + +import { AIProvider } from '../provider.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const replaceText = (text: Record, template: any) => { + if (template != null && typeof template === 'object') { + if (Array.isArray(template)) { + template.forEach(v => replaceText(text, v)); + return; + } + if (typeof template.insert === 'string') { + template.insert = text[template.insert] ?? template.insert; + } + Object.values(template).forEach(v => replaceText(text, v)); + return; + } +}; + +function seededRNG(seed: string) { + const a = 1664525; + const c = 1013904223; + const m = 2 ** 32; + const seededNumber = stringToNumber(seed); + return ((a * seededNumber + c) % m) / m; + + function stringToNumber(str: string) { + let res = 0; + for (let i = 0; i < str.length; i++) { + const character = str.charCodeAt(i); + res += character; + } + return res; + } +} + +const getImageUrlByKeyword = + (keyword: string) => + async (w: number, h: number): Promise => { + const photos = await AIProvider.photoEngine?.searchImages({ + query: keyword, + width: w, + height: h, + }); + + if (photos == null || photos.length === 0) { + return ''; // fixme: give a default image + } + + // use a stable random seed + const rng = seededRNG(`${w}.${h}.${keyword}`); + return photos[Math.floor(rng * photos.length)]; + }; + +const getImages = async ( + images: Record Promise | string>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + template: any +): Promise => { + const imgs: Record< + string, + { + id: string; + width: number; + height: number; + } + > = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const run = (data: any) => { + if (data != null && typeof data === 'object') { + if (Array.isArray(data)) { + data.forEach(v => run(v)); + return; + } + if (typeof data.caption === 'string') { + const bound = Bound.deserialize(data.xywh); + const id = nanoid(); + data.sourceId = id; + imgs[data.caption] = { + id: id, + width: bound.w, + height: bound.h, + }; + delete data.caption; + } + Object.values(data).forEach(v => run(v)); + return; + } + }; + run(template); + const list = await Promise.all( + Object.entries(imgs).map(async ([name, data]) => { + const getImage = images[name]; + if (!getImage) { + return; + } + const url = await getImage(data.width, data.height); + return { + id: data.id, + url, + } satisfies TemplateImage; + }) + ); + const notNull = (v?: TemplateImage): v is TemplateImage => { + return v != null; + }; + return list.filter(notNull); +}; + +export type PPTSection = { + title: string; + content: string; + keywords: string; +}; + +export type TemplateImage = { + id: string; + url: string; +}; + +type DocTemplate = { + images: TemplateImage[]; + content: unknown; +}; + +const createBasicCover = async ( + title: string, + section1: PPTSection +): Promise => { + const templates = (await import('./templates/cover.json')).default; + const template = getRandomElement(templates); + replaceText( + { + title: title, + 'section1.title': section1.title, + 'section1.content': section1.content, + }, + template + ); + return { + images: await getImages( + { + 'section1.image': getImageUrlByKeyword(section1.keywords), + }, + template + ), + content: template, + }; +}; + +function getRandomElement(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +const basic1section = async ( + title: string, + section1: PPTSection +): Promise => { + const templates = (await import('./templates/one.json')).default; + const template = getRandomElement(templates); + replaceText( + { + title: title, + 'section1.title': section1.title, + 'section1.content': section1.content, + }, + template + ); + const images = await getImages( + { + 'section1.image': getImageUrlByKeyword(section1.keywords), + 'section1.image2': getImageUrlByKeyword(section1.keywords), + 'section1.image3': getImageUrlByKeyword(section1.keywords), + }, + template + ); + + return { + images: images, + content: template, + }; +}; + +const basic2section = async ( + title: string, + section1: PPTSection, + section2: PPTSection +): Promise => { + const templates = (await import('./templates/two.json')).default; + const template = getRandomElement(templates); + replaceText( + { + title: title, + 'section1.title': section1.title, + 'section1.content': section1.content, + 'section2.title': section2.title, + 'section2.content': section2.content, + }, + template + ); + return { + images: await getImages( + { + 'section1.image': getImageUrlByKeyword(section1.keywords), + 'section2.image': getImageUrlByKeyword(section2.keywords), + background: () => + 'https://cdn.affine.pro/ppt-images/background/basic_2_selection_background.png', + }, + template + ), + content: template, + }; +}; + +const basic3section = async ( + title: string, + section1: PPTSection, + section2: PPTSection, + section3: PPTSection +): Promise => { + const templates = (await import('./templates/three.json')).default; + const template = getRandomElement(templates); + replaceText( + { + title: title, + 'section1.title': section1.title, + 'section1.content': section1.content, + 'section2.title': section2.title, + 'section2.content': section2.content, + 'section3.title': section3.title, + 'section3.content': section3.content, + }, + template + ); + return { + images: await getImages( + { + 'section1.image': getImageUrlByKeyword(section1.keywords), + 'section2.image': getImageUrlByKeyword(section2.keywords), + 'section3.image': getImageUrlByKeyword(section3.keywords), + background: () => + 'https://cdn.affine.pro/ppt-images/background/basic_3_selection_background.png', + }, + template + ), + content: template, + }; +}; + +const basic4section = async ( + title: string, + section1: PPTSection, + section2: PPTSection, + section3: PPTSection, + section4: PPTSection +): Promise => { + const templates = (await import('./templates/four.json')).default; + const template = getRandomElement(templates); + replaceText( + { + title: title, + 'section1.title': section1.title, + 'section1.content': section1.content, + 'section2.title': section2.title, + 'section2.content': section2.content, + 'section3.title': section3.title, + 'section3.content': section3.content, + 'section4.title': section4.title, + 'section4.content': section4.content, + }, + template + ); + return { + images: await getImages( + { + 'section1.image': getImageUrlByKeyword(section1.keywords), + 'section2.image': getImageUrlByKeyword(section2.keywords), + 'section3.image': getImageUrlByKeyword(section3.keywords), + 'section4.image': getImageUrlByKeyword(section4.keywords), + background: () => + 'https://cdn.affine.pro/ppt-images/background/basic_4_selection_background.png', + }, + template + ), + content: template, + }; +}; + +export type PPTDoc = { + isCover: boolean; + title: string; + sections: PPTSection[]; +}; + +export const basicTheme = (doc: PPTDoc) => { + if (doc.isCover) { + return createBasicCover(doc.title, doc.sections[0]); + } + if (doc.sections.length === 1) { + return basic1section(doc.title, doc.sections[0]); + } + if (doc.sections.length === 2) { + return basic2section(doc.title, doc.sections[0], doc.sections[1]); + } + if (doc.sections.length === 3) { + return basic3section( + doc.title, + doc.sections[0], + doc.sections[1], + doc.sections[2] + ); + } + return basic4section( + doc.title, + doc.sections[0], + doc.sections[1], + doc.sections[2], + doc.sections[3] + ); +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/cover.json b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/cover.json new file mode 100644 index 0000000000..a447cc3f60 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/cover.json @@ -0,0 +1,97 @@ +[ + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1706086837052, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "QiBH29dycZ", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "WZ9zUizVrs", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "DcoFASHQKI": { + "index": "a1", + "seed": 1391437441, + "xywh": "[-83.54346701558063,-50.225603646128235,1600.17596435546875,179]", + "rotate": 0, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "section1.title" + } + ] + }, + "color": "--affine-palette-line-black", + "fontSize": 128, + "fontFamily": "blocksuite:surface:Poppins", + "fontWeight": "400", + "fontStyle": "normal", + "textAlign": "left", + "type": "text", + "id": "DcoFASHQKI", + "hasMaxWidth": false + } + } + }, + "children": [ + { + "type": "block", + "id": "Rb6xTvGyzU", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "background", + "sourceId": "xeeMw2R2FtjRo2Q0H14rsCpImSR9-z54W_PDZUsFvJ8=", + "width": 1920, + "height": 1080, + "index": "a0", + "xywh": "[-300.7451171875,-355.744140625,1920,1080]", + "rotate": 0, + "size": 938620 + }, + "children": [] + }, + { + "type": "block", + "id": "Pa1ZO7Udi-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-340.7451171875,-395.744140625,2000,1160]", + "index": "a0" + }, + "children": [] + } + ] + } + ] + } + } +] diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/four.json b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/four.json new file mode 100644 index 0000000000..4440597c1d --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/four.json @@ -0,0 +1,1132 @@ +[ + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711619462579, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "Z1ykl12cSx", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "nk3_3Ly7J4", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "dvNokXHFjJ": { + "index": "a2", + "seed": 1481900971, + "xywh": "[-1983.9163177173398,370.2741425054713,68.49881720241342,68.49881720241348]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "dvNokXHFjJ", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "pjLp5iRHj1": { + "index": "a7", + "seed": 1481900971, + "xywh": "[-1087.3515614427831,370.4416324567778,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "pjLp5iRHj1", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "vrH9SvPlB6": { + "index": "aB", + "seed": 1481900971, + "xywh": "[-1087.3515614427831,730.8057512329431,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "vrH9SvPlB6", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "4" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "UF3qaMw35x": { + "index": "aF", + "seed": 1481900971, + "xywh": "[-1983.8238766248692,730.8057512329431,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "UF3qaMw35x", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "TVg0_H3pS-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "index": "a0" + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "lLFwOmRC7W", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-2053.238012404669,-9.923971266973126,1341.3334350585938,283.6666920979818]", + "background": "--affine-palette-transparent", + "index": "a1", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 122.66668192545575, + "scale": 2.481050005858841 + } + }, + "children": [ + { + "type": "block", + "id": "HQ-jnOE1f3", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "FL__cfBte4", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1957.8756949308126,298.9828272123103,810.9499838439583,216.11059503352658]", + "background": "--affine-palette-transparent", + "index": "a1V", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.227884570999885, + "collapse": true, + "collapsedHeight": 94.5297571493372 + } + }, + "children": [ + { + "type": "block", + "id": "UdkuWp8Pq1", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "SplfMbP19A", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1949.666909116133,409.4267986521621,747.1235944841051,274.25339814778795]", + "background": "--affine-palette-transparent", + "index": "a1l", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.8584539953344592, + "collapse": true, + "collapsedHeight": 147.5707221358641 + } + }, + "children": [ + { + "type": "block", + "id": "aexenplBOM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "hkkjrzh5U2", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1060.8108545830105,298.9828272123103,810.9499838439583,216.11059503352658]", + "background": "--affine-palette-transparent", + "index": "a5", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.227884570999885, + "collapse": true, + "collapsedHeight": 94.5297571493372 + } + }, + "children": [ + { + "type": "block", + "id": "tfckfWqMY2", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "CZkdeTlqzf", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1052.6020687683308,409.4267986521621,747.1235944841051,274.25339814778795]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.8584539953344592, + "collapse": true, + "collapsedHeight": 147.5707221358641 + } + }, + "children": [ + { + "type": "block", + "id": "FJwQSWbMrK", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "dCmC6nD4oM", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1060.8108545830105,659.9727569688716,810.9499838439583,216.11059503352658]", + "background": "--affine-palette-transparent", + "index": "a9", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.227884570999885, + "collapse": true, + "collapsedHeight": 94.5297571493372 + } + }, + "children": [ + { + "type": "block", + "id": "hsaCDcCh4T", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "GbdYivh7V_", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1052.6020687683308,770.4167284087234,747.1235944841051,274.25339814778795]", + "background": "--affine-palette-transparent", + "index": "aA", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.8584539953344592, + "collapse": true, + "collapsedHeight": 147.5707221358641 + } + }, + "children": [ + { + "type": "block", + "id": "m5nGW684z7", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "heDlgO5-AP", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1957.8756949308124,659.9727569688716,810.9499838439583,216.11059503352658]", + "background": "--affine-palette-transparent", + "index": "aD", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.227884570999885, + "collapse": true, + "collapsedHeight": 94.5297571493372 + } + }, + "children": [ + { + "type": "block", + "id": "e7i3FY50Y9", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section4.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aTPSWy7isi", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1949.6669091161327,770.4167284087234,747.1235944841051,274.25339814778795]", + "background": "--affine-palette-transparent", + "index": "aE", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.8584539953344592, + "collapse": true, + "collapsedHeight": 147.5707221358641 + } + }, + "children": [ + { + "type": "block", + "id": "eKBpvD2seQ", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section4.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1509.6661094585195,-2898.612292265666,1960.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1509.6661094585195,-2898.612292265666,1960.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "2Y9Hkx2NH_", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "acV", + "xywh": "[-1477.8992703984247,-2695.0139716592407,442.8571428571429,291.42860049293176]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "wfAA7nMj9S", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section2.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "xywh": "[-1016.5659574101433,-2695.0139716592407,442.8571428571429,291.42860049293176]", + "index": "ae", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "v0kbq9Ge2L", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section3.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "xywh": "[-555.2326444218619,-2695.0139716592407,442.8571428571429,291.42860049293176]", + "index": "ag", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "U0VKdP1QXx", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section4.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "xywh": "[-93.89933143358057,-2695.0139716592407,442.8571428571429,291.42860049293176]", + "index": "ai", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-1505.2500792932615,-2892.496670058301,1870.0890697914701,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 135.67362421639123 + }, + "xywh": "[-1509.6661094585195,-2449.148531089114,589.0618769832358,219.56087840116928]" + }, + "children": [ + { + "type": "block", + "id": "Z0vZzczM9m", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1503.264569392059,-2279.702158995507,505.068393234737,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "nVm30fhD_V", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "SWuXaO4zVD", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1048.5978722078141,-2279.702158995507,505.068393234737,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "am", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "m44JtQOkYX", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "dUtL3CwWVp", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1054.9994122742746,-2449.148531089114,589.0618769832358,219.56087840116928]", + "background": "--affine-palette-transparent", + "index": "al", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 135.67362421639123 + } + }, + "children": [ + { + "type": "block", + "id": "_ufa27y_Mw", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "LUjOSttIUe", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-589.9311851960954,-2279.702158995507,505.068393234737,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "aq", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "QyiTDLrbQs", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "SgzW7o7hMY", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-596.3327252625559,-2449.148531089114,589.0618769832358,219.56087840116928]", + "background": "--affine-palette-transparent", + "index": "ap", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 135.67362421639123 + } + }, + "children": [ + { + "type": "block", + "id": "PE0lR8TFaS", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "3yZ-tCMJ5n", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-132.59791289791826,-2279.702158995507,505.068393234737,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "au", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "NIHjcRX-f8", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section4.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "KK8h_OiRqv", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-138.99945296437875,-2449.148531089114,589.0618769832358,219.56087840116928]", + "background": "--affine-palette-transparent", + "index": "at", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 135.67362421639123 + } + }, + "children": [ + { + "type": "block", + "id": "hv1g2gHWbM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section4.title" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + } +] diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/one.json b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/one.json new file mode 100644 index 0000000000..8a9f1c0650 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/one.json @@ -0,0 +1,1544 @@ +[ + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "one section", + "createDate": 1711610833606, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "one section" + } + ] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "index": "a2" + }, + "children": [] + }, + { + "type": "block", + "id": "Kl_nXMFhVd", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aI", + "xywh": "[-1514.2376426616445,-2905.469439482463,527.3333333333333,1087.2819474283258]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-922.880340452472,-2825.1459576709904,1212.5651934196321,236.44460482053137]", + "background": "--affine-palette-transparent", + "index": "aB", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + } + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-910.0113897833639,-2608.958384791207,1198.7335362065319,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aF", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "CXWd92w-Qi", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-900.4706500368766,-2493.7715542849137,1187.6524039191502,131.73112646502364]", + "background": "--affine-palette-transparent", + "index": "aH", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.2393327741958684, + "collapse": true, + "collapsedHeight": 106.29197355851124 + } + }, + "children": [ + { + "type": "block", + "id": "WCIwmm9A3s", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711610833606, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "index": "a2" + }, + "children": [] + }, + { + "type": "block", + "id": "Kl_nXMFhVb", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aI", + "xywh": "[-161.93471756298595,-2905.227506715997,527.3333333333333,1085.102943835111]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1467.7081022991783,-2825.1459576709904,1212.5651934196321,236.44460482053137]", + "background": "--affine-palette-transparent", + "index": "aB", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + } + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1454.8391516300703,-2608.958384791207,1198.7335362065319,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aF", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "CXWd92w-Qi", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1445.298411883583,-2493.7715542849137,1187.6524039191502,131.73112646502364]", + "background": "--affine-palette-transparent", + "index": "aH", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.2393327741958684, + "collapse": true, + "collapsedHeight": 106.29197355851124 + } + }, + "children": [ + { + "type": "block", + "id": "WCIwmm9A3s", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711610833606, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "index": "a2" + }, + "children": [] + }, + { + "type": "block", + "id": "Kl_nXMFhVc", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aI", + "xywh": "[-161.93471756298595,-2905.227506715997,527.3333333333333,1085.102943835111]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1467.7081022991783,-2676.3146218911334,1212.5651934196321,236.44460482053137]", + "background": "--affine-palette-transparent", + "index": "aB", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + } + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1454.8391516300703,-2457.6331564505895,1198.7335362065319,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aF", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "CXWd92w-Qi", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1445.298411883583,-2342.446325944296,1187.6524039191502,131.73112646502364]", + "background": "--affine-palette-transparent", + "index": "aH", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.2393327741958684, + "collapse": true, + "collapsedHeight": 106.29197355851124 + } + }, + "children": [ + { + "type": "block", + "id": "WCIwmm9A3s", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "Kl_nXMFhVa", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aI", + "xywh": "[-1514.2376426616445,-2905.227506715997,527.3333333333333,1085.102943835111]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-910.7394049209178,-2676.3146218911334,1212.5651934196321,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + }, + "xywh": "[-897.8704542518097,-2457.6331564505895,1198.7335362065319,174.0265670977942]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-916.6630478386558,-2362.2267494910393,1214.0190493549892,257.958640225178]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 117.95864022517799, + "scale": 2.401823909123141 + } + }, + "children": [ + { + "type": "block", + "id": "FgPYlSJXiV", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711619462579, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "Z1ykl12cSx", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "nk3_3Ly7J4", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "TVg0_H3pS-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "index": "a0" + }, + "children": [] + }, + { + "type": "block", + "id": "SphbFXlgh0", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aX", + "xywh": "[-2101.800067858677,1.2,654.4250479902054,338.81577464204423]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "_Tf8693XKA", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image2", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "a7", + "xywh": "[-2101.800067858677,379.2497670673074,654.4250479902054,338.81577464204423]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "UNpbRMah1C", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image3", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aZ", + "xywh": "[-2101.800067858677,758,654.4250479902054,338.81577464204423]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aWw5W79SOg", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1354.6692632128902,343.3351440816688,1127.5440924085192,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "a5", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "AkKDNgzKb_", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "5zMsJG3EQa", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1351.148559784478,459.149012829487,1120.7742609602965,403.49738953307815]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 213.040363075911 + } + }, + "children": [ + { + "type": "block", + "id": "kt2gfL8Bn7", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711619462579, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "Z1ykl12cSx", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "nk3_3Ly7J4", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "TVg0_H3pS-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "index": "a0" + }, + "children": [] + }, + { + "type": "block", + "id": "NLvbZwU4PA", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aX", + "xywh": "[-803.7851622517169,611.1159327724056,632.7030330086453,327.5696260489153]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "HZB7i-Ju4k", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image2", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "a7", + "xywh": "[-1452.7926216327571,611.1159327724056,632.7030330086453,327.5696260489153]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "UNpbRMah1C", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image3", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aZ", + "xywh": "[-2101,611.1159327724056,632.7030330086453,327.5696260489153]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aWw5W79SOg", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1700.213151332694,119.59413792685919,1127.5440924085192,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "a5", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "AkKDNgzKb_", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "5zMsJG3EQa", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1696.8282356085826,235.40800667467738,1120.7742609602965,328.06883554312276]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 173.21525653208363 + } + }, + "children": [ + { + "type": "block", + "id": "kt2gfL8Bn7", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711619462579, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "Z1ykl12cSx", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "nk3_3Ly7J4", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "TVg0_H3pS-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "index": "a0" + }, + "children": [] + }, + { + "type": "block", + "id": "FJGkatSaly", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "a7", + "xywh": "[-824.172570496749,21.294843323820828,633.142822265625,548.1428571428572]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "8PC-diRv-2", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image2", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "a9", + "xywh": "[-1224.1725704967491,592.7234426934307,1033.1428222656252,477.28566196986594]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "6MPeRodrBk", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image3", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aB", + "xywh": "[-2074.45838941416,592.7234426934307,825.1428571428576,477.28566196986594]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "lLFwOmRC7W", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-2000.666688464937,74.64747474307153,1156.1906127929688,283.6666920979818]", + "background": "--affine-palette-transparent", + "index": "a1", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 122.66668192545575, + "scale": 2.481050005858841 + } + }, + "children": [ + { + "type": "block", + "id": "HQ-jnOE1f3", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "CZkdeTlqzf", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1976.4593162571703,258.7718028519709,1133.4093436470516,274.25339814778795]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.8584539953344592, + "collapse": true, + "collapsedHeight": 147.5707221358641 + } + }, + "children": [ + { + "type": "block", + "id": "FJwQSWbMrK", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + } +] diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/three.json b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/three.json new file mode 100644 index 0000000000..fdb4dbaccd --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/three.json @@ -0,0 +1,3800 @@ +[ + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "DjqtK4cIvA": { + "index": "aM", + "seed": 378347608, + "xywh": "[-1462.4448858546439,-2671.393416157706,67.74408298065555,68.33333333333334]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "DjqtK4cIvA", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + } + }, + "lKm7_gD7iU": { + "index": "aQ", + "seed": 378347608, + "xywh": "[-812.4550145808457,-2671.719165096266,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "lKm7_gD7iU", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + } + }, + "AV7GyFSjSC": { + "index": "aU", + "seed": 378347608, + "xywh": "[-1462.4550145808457,-2190.052498429599,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "AV7GyFSjSC", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + } + }, + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "25om7PUFbo", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aA", + "xywh": "[-161.93471756298595,-2905.227506715997,527.3333333333333,1085.102943835111]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-1434.996381171989,-2894.647955224467,1212.5651934196321,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + }, + "xywh": "[-1416.2037875851431,-2732.6331564505895,589.0618769832358,174.0265670977942]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1409.5371209184764,-2634.1760451184673,577.0683118545286,385.42432914117944]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 277.7731841600028, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "FgPYlSJXiV", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "UR7y5DUg6J", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-765.9487034393142,-2732.6331564505895,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aO", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "-vGl5QJJY0", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "jBhX1Rxtg_", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-759.2820367726474,-2634.1760451184673,582.4016655329141,382.757652301987]", + "background": "--affine-palette-transparent", + "index": "aP", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 275.8513248980337, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "S6KKaAhUdj", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "YVjRYKRUu5", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1416.2037875851431,-2253.571851708401,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aS", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "5jyhK9QUxn", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aLe21NECWh", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1409.5371209184764,-2155.114740376279,1237.0683118545285,249.42431896865355]", + "background": "--affine-palette-transparent", + "index": "aT", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 179.75872836373244, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "o4dQHdp6sC", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "DjqtK4cIvA": { + "index": "aM", + "seed": 378347608, + "xywh": "[-948.1590843758491,-2671.393416157706,67.74408298065555,68.33333333333334]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "DjqtK4cIvA", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + } + }, + "lKm7_gD7iU": { + "index": "aQ", + "seed": 378347608, + "xywh": "[-298.169213102051,-2671.719165096266,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "lKm7_gD7iU", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + } + }, + "AV7GyFSjSC": { + "index": "aU", + "seed": 378347608, + "xywh": "[-948.169213102051,-2190.052498429599,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "AV7GyFSjSC", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + } + }, + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "25om7PUFbo", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aA", + "xywh": "[-1514.2376426616445,-2905.227506715997,527.3333333333333,1085.102943835111]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-920.7105796931943,-2894.647955224467,1212.5651934196321,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + }, + "xywh": "[-901.9179861063484,-2732.6331564505895,589.0618769832358,174.0265670977942]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-895.2513194396817,-2634.1760451184673,577.0683118545286,385.42432914117944]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 277.7731841600028, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "FgPYlSJXiV", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "UR7y5DUg6J", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-251.66290196051943,-2732.6331564505895,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aO", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "-vGl5QJJY0", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "jBhX1Rxtg_", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-244.9962352938527,-2634.1760451184673,582.4016655329141,382.757652301987]", + "background": "--affine-palette-transparent", + "index": "aP", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 275.8513248980337, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "S6KKaAhUdj", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "YVjRYKRUu5", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-901.9179861063484,-2253.571851708401,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aS", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "5jyhK9QUxn", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aLe21NECWh", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-895.2513194396817,-2155.114740376279,1237.0683118545285,249.42431896865355]", + "background": "--affine-palette-transparent", + "index": "aT", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 179.75872836373244, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "o4dQHdp6sC", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "jHwkvWX2SP", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aU", + "xywh": "[-1434.0269169013459,-2671.624042427925,507.9999542236329,305.14294215611017]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "lys4Z03uxm", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section2.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aW", + "xywh": "[-819.3433432898528,-2671.624042427925,507.9999542236329,305.14294215611017]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "9cUOfpMZKC", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section3.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aY", + "xywh": "[-213.06720179638148,-2671.624042427925,507.9999542236329,305.14294215611017]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-1486.7077268312114,-2894.647955224467,1212.5651934196321,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + }, + "xywh": "[-1469.4870071046314,-2412.6331564505895,589.0618769832358,174.0265670977942]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1462.8203404379647,-2314.175991080571,577.0683118545286,403.7100259882777]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 290.9515848310785, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "FgPYlSJXiV", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "UR7y5DUg6J", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-863.2108656111601,-2412.6331564505895,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aO", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "-vGl5QJJY0", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "jBhX1Rxtg_", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-856.5441989444935,-2314.1759910651094,582.4016655329141,400.9168339278503]", + "background": "--affine-palette-transparent", + "index": "aP", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 288.9385467482875, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "S6KKaAhUdj", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "cEIG52fU4j", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-250.26805745102206,-2314.1759910651085,582.4016655329141,400.9168339278503]", + "background": "--affine-palette-transparent", + "index": "aT", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 288.9385467482875, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "1hMBS097nC", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "oq8oMI8d-n", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-256.9347241176888,-2412.633156450589,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aS", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "ZdWSdxlpOr", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "-Z2wOyOCUl": { + "index": "aV", + "seed": 378347608, + "xywh": "[-1430.8901094973382,-2614.8384684816765,67.74408298065555,68.33333333333334]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "-Z2wOyOCUl", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + } + }, + "LdVe2K2EQN": { + "index": "aX", + "seed": 378347608, + "xywh": "[-827.0027333037912,-2614.721557410709,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "LdVe2K2EQN", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + } + }, + "Vssc32X8gW": { + "index": "aZ", + "seed": 378347608, + "xywh": "[-223.11535711024408,-2614.721557410709,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "Vssc32X8gW", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + } + }, + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-1486.7077268312114,-2894.647955224467,1212.5651934196321,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + }, + "xywh": "[-1469.4870071046314,-2591.299792599678,589.0618769832358,174.0265670977942]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1462.8203404379647,-2474.1757884317985,577.0683118545286,549.9957751512243]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 396.37891587882604, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "FgPYlSJXiV", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "UR7y5DUg6J", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-863.2108656111601,-2591.299792599678,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aO", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "-vGl5QJJY0", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "jBhX1Rxtg_", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-856.5441989444935,-2474.175788470573,582.4016655329141,546.1904601143724]", + "background": "--affine-palette-transparent", + "index": "aP", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 393.63644635991017, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "S6KKaAhUdj", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "cEIG52fU4j", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-250.26805745102206,-2474.1757884705717,582.4016655329141,546.1904601143724]", + "background": "--affine-palette-transparent", + "index": "aT", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 393.63644635991017, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "1hMBS097nC", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "oq8oMI8d-n", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-256.9347241176888,-2591.2997925996774,589.0618769832358,174.0265670977942]", + "background": "--affine-palette-transparent", + "index": "aS", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 97.69366829020107 + } + }, + "children": [ + { + "type": "block", + "id": "ZdWSdxlpOr", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "-Z2wOyOCUl": { + "index": "aV", + "seed": 378347608, + "xywh": "[-915.3586377393509,-2638.671822165036,67.74408298065555,68.33333333333334]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "-Z2wOyOCUl", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + } + }, + "Fmo2Tcj8Fl": { + "index": "aZ", + "seed": 378347608, + "xywh": "[-914.6006596743409,-2375.357453090786,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "Fmo2Tcj8Fl", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + } + }, + "bDXVat8dj4": { + "index": "ad", + "seed": 378347608, + "xywh": "[-914.6006596743409,-2112.500310233643,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "bDXVat8dj4", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + } + }, + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1514.2376426616445,-2905.469439482463,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "u4QIphd0DC", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aM", + "xywh": "[-1509.8216124963865,-2903.9812987303267,526.4761425199962,1081.3333347865514]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-971.1762550732241,-2894.6479552244673,1283.422301399543,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 109.30854828921595 + }, + "xywh": "[-869.9555772679123,-2700.442671944919,1219.8729704453162,176.8942269932918]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-863.5540372014515,-2616.3296026670673,1216.9527533796265,180.2289617524957]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 129.8900167910892, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "iPmO1_z9Gt", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "zYl5jpauG1", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-869.9555772679123,-2438.342789709367,1219.8729704453162,176.8942269932918]", + "background": "--affine-palette-transparent", + "index": "aX", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 109.30854828921595 + } + }, + "children": [ + { + "type": "block", + "id": "ei9lkUZYoa", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "w6VD-syBgm", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-863.5540372014515,-2354.2297204315155,1216.9527533796265,180.2289617524957]", + "background": "--affine-palette-transparent", + "index": "aY", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 129.8900167910892, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "GekGjqgOXO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "jN9kkO5zW0", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-869.9555772679123,-2173.2705763363133,1219.8729704453162,176.8942269932918]", + "background": "--affine-palette-transparent", + "index": "ab", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 109.30854828921595 + } + }, + "children": [ + { + "type": "block", + "id": "Yw1-3TH_y1", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "8ezRSvK27E", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-863.5540372014515,-2089.1575070584618,1216.9527533796265,180.2289617524957]", + "background": "--affine-palette-transparent", + "index": "ac", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 129.8900167910892, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "YaMbPW1v75", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711615521774, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "aa6W7J2mx2", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "PMd-PuXWK7", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "-Z2wOyOCUl": { + "index": "aV", + "seed": 378347608, + "xywh": "[-1449.4324619593883,-2345.0758660422707,67.74408298065555,68.33333333333334]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "-Z2wOyOCUl", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + } + }, + "akLeN9eL3Z": { + "index": "aZ", + "seed": 378347608, + "xywh": "[-826.8947976664532,-2345.525578446396,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "akLeN9eL3Z", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + } + }, + "oNyyPV1iJg": { + "index": "ad", + "seed": 378347608, + "xywh": "[-199.14972218215394,-2345.525578446396,67.74408298065555,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "oNyyPV1iJg", + "color": "--affine-palette-line-white", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40, + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + } + }, + "TJhFxmm-4S": { + "index": "a1", + "seed": 1730805220, + "xywh": "[-1509.6661094585195,-2898.612292265666,1879.6362584319918,1087.2819474283258]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "CnuFtpWnKL", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "index": "a2", + "xywh": "[-1509.6661094585195,-2898.612292265666,1879.6362584319918,1087.2819474283258]" + }, + "children": [] + }, + { + "type": "block", + "id": "u4QIphd0DC", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aM", + "xywh": "[-1505.2500792932615,-2897.12415151353,1872.7618916829429,375.04758562360485]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "vep0c6lfir", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aB", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.111112543040462, + "collapse": true, + "collapsedHeight": 111.99999999999983 + }, + "xywh": "[-1505.2500792932615,-2547.3538041961356,1870.0890697914701,236.44460482053137]" + }, + "children": [ + { + "type": "block", + "id": "fSKn6ISVDM", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "sm_bXJ4OIr", + "flavour": "affine:note", + "version": 1, + "props": { + "background": "--affine-palette-transparent", + "hidden": false, + "displayMode": "edgeless", + "index": "aF", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 109.30854828921595 + }, + "xywh": "[-1486.6960783271422,-2335.8151977557804,589.0618769832358,176.8942269932918]" + }, + "children": [ + { + "type": "block", + "id": "8B4rgaGfZO", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "-e1FBf1VVV", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1480.2945382606815,-2251.702128477929,578.2112678162102,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "aL", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "iPmO1_z9Gt", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "wkyVSsZhvb", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-863.400071943408,-2335.8151977557804,589.0618769832358,176.8942269932918]", + "background": "--affine-palette-transparent", + "index": "aX", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 109.30854828921595 + } + }, + "children": [ + { + "type": "block", + "id": "TI-VP1M95O", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "o27HkMC_--", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-856.9985318769473,-2251.702128477929,578.2112678162102,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "aY", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "VFwX1ginQI", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "VJ0XDj4Fpr", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-235.65499645910882,-2335.8151977557804,589.0618769832358,176.8942269932918]", + "background": "--affine-palette-transparent", + "index": "ab", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.6183018598440544, + "collapse": true, + "collapsedHeight": 109.30854828921595 + } + }, + "children": [ + { + "type": "block", + "id": "-iCGy0T361", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "SnAzf_Dy6D", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-229.2534563926481,-2251.702128477929,578.2112678162102,409.5622543957249]", + "background": "--affine-palette-transparent", + "index": "ac", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "collapse": true, + "collapsedHeight": 295.169253504954, + "scale": 1.387550530864662 + } + }, + "children": [ + { + "type": "block", + "id": "GlVIaFF6sF", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h2", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711619462579, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "Z1ykl12cSx", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "nk3_3Ly7J4", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "UVupGyyZN1": { + "index": "a8", + "seed": 976364174, + "xywh": "[-2009.1406565044413,1.1999424525670292,6.286634692326288,1095.5964434998189]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "UVupGyyZN1", + "color": "--affine-palette-line-black", + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "NJxtDRr8Wz": { + "index": "aA", + "seed": 1481900971, + "xywh": "[-2037.1821400826323,194.25718279849224,68.49881720241342,68.49881720241348]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "NJxtDRr8Wz", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "Og7Aa0FT4P": { + "index": "aC", + "seed": 1481900971, + "xywh": "[-2037.182140082632,515.3284845766241,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "Og7Aa0FT4P", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "xurVVzewkV": { + "index": "aE", + "seed": 1481900971, + "xywh": "[-2037.182140082632,835.9009691523424,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "xurVVzewkV", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "TVg0_H3pS-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "index": "a0" + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aWw5W79SOg", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-2000.764655559536,43.90652033724916,1164.4965343960564,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "a5", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "AkKDNgzKb_", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "5zMsJG3EQa", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1997.764655559536,134.577479626362,1819.3423114296422,286.9259086458013]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 151.49242929309233 + } + }, + "children": [ + { + "type": "block", + "id": "kt2gfL8Bn7", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "acZgwhfCWY", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1997.764655559536,457.07244815577957,1819.3423114296422,286.9259086458013]", + "background": "--affine-palette-transparent", + "index": "aG", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 151.49242929309233 + } + }, + "children": [ + { + "type": "block", + "id": "8vRFcSMg1V", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "YeHO9NCElw", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-2000.764655559536,366.40148886666674,1164.4965343960564,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "aF", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "mHjARC9Fhu", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "BHC3GyNAdu", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1997.764655559536,779.567416685197,1819.3423114296422,286.9259086458013]", + "background": "--affine-palette-transparent", + "index": "aK", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 151.49242929309233 + } + }, + "children": [ + { + "type": "block", + "id": "y5Jbozh1vt", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "I82USJCVZw", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-2000.764655559536,688.8964573960843,1164.4965343960564,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "aJ", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "GejxhqB-wj", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + }, + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1711619462579, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "Z1ykl12cSx", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "nk3_3Ly7J4", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "NJxtDRr8Wz": { + "index": "aA", + "seed": 1481900971, + "xywh": "[-1962.5154937610178,107.58622350937945,68.49881720241342,68.49881720241348]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "NJxtDRr8Wz", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "1" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "Lu4iLMmzDn": { + "index": "aQ", + "seed": 1481900971, + "xywh": "[-1136.8615154265685,439.7072383836723,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "Lu4iLMmzDn", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "2" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "TOtG2mlRAJ": { + "index": "aU", + "seed": 1481900971, + "xywh": "[-1962.194869104954,720.7809129074261,68.49881720241342,68]", + "rotate": 0, + "shapeType": "ellipse", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-navy", + "strokeWidth": 2, + "strokeColor": "--affine-palette-transparent", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TOtG2mlRAJ", + "color": "--affine-palette-line-white", + "text": { + "affine:surface:text": true, + "delta": [ + { + "insert": "3" + } + ] + }, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 40 + }, + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "TVg0_H3pS-", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-2101.800074986049,1.1999424525670292,1933.7142508370534,1095.9999738420759]", + "index": "a0" + }, + "children": [] + }, + { + "type": "block", + "id": "UBMo_osmTh", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section1.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aV", + "xywh": "[-1893.6960519025406,418.3619095202094,457.3333740234375,236.77541357147118]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "NLvbZwU4PA", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section2.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aX", + "xywh": "[-783.0291919578789,134.577479626362,457.3333740234375,236.77541357147118]", + "rotate": 0, + "size": 3837 + }, + "children": [] + }, + { + "type": "block", + "id": "-PNfQ_u1PE", + "flavour": "affine:image", + "version": 1, + "props": { + "caption": "section3.image", + "sourceId": "PiQbzGAzz2UgK4kqceCMRwNJGD27VuPXmkUV1VIdUpI=", + "width": 150, + "height": 150, + "index": "aZ", + "xywh": "[-783.0291919578789,747.4910080481044,457.3333740234375,236.77541357147118]", + "rotate": 0, + "size": 3837 + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aWw5W79SOg", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1926.0980092379216,43.90652033724916,921.8298066942334,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "a5", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "AkKDNgzKb_", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "5zMsJG3EQa", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1923.0980092379216,134.577479626362,918.008998441361,286.9259086458013]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 151.49242929309233 + } + }, + "children": [ + { + "type": "block", + "id": "kt2gfL8Bn7", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "S53e3TYzsE", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1100.2521508241273,375.28962019689266,775.1631400275667,222.8494796636565]", + "background": "--affine-palette-transparent", + "index": "aO", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.41767859344831 + } + }, + "children": [ + { + "type": "block", + "id": "y0bj85ZiWg", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "A-DYYZeyH3", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1097.2521508241273,465.9605794860054,764.6756651080276,286.9259086458014]", + "background": "--affine-palette-transparent", + "index": "aP", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 151.49242929309239 + } + }, + "children": [ + { + "type": "block", + "id": "zWwZJYEzgz", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "u2tLVHn1OH", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1926.0980092379218,656.8200487589916,921.8298066942334,222.84947966365655]", + "background": "--affine-palette-transparent", + "index": "aS", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 2, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.947683980335685, + "collapse": true, + "collapsedHeight": 114.01012331779694 + } + }, + "children": [ + { + "type": "block", + "id": "8kjIjQLSP5", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h4", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "h65JXM7SYI", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-1923.0980092379218,747.4910080481044,918.008998441361,286.9259086458013]", + "background": "--affine-palette-transparent", + "index": "aT", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 0, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.89399503318206, + "collapse": true, + "collapsedHeight": 151.49242929309233 + } + }, + "children": [ + { + "type": "block", + "id": "7yVFZDxhWf", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section3.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + } +] diff --git a/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/two.json b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/two.json new file mode 100644 index 0000000000..194958f76e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/slides/templates/two.json @@ -0,0 +1,297 @@ +[ + { + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1712567726055, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "A4KjEzkDl0", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "OpVjGBTYuZ", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "TJhFxmm-4S": { + "index": "Zz", + "seed": 1730805220, + "xywh": "[-509.7051213118268,-469.55707155770506,1924.0508676236536,1089.1141431154101]", + "rotate": 0, + "shapeType": "rect", + "radius": 0, + "filled": true, + "fillColor": "--affine-palette-shape-white", + "strokeWidth": 2, + "strokeColor": "--affine-palette-line-white", + "strokeStyle": "solid", + "shapeStyle": "General", + "roughness": 1.4, + "textResizing": 1, + "maxWidth": false, + "type": "shape", + "id": "TJhFxmm-4S", + "color": "--affine-palette-line-black" + } + } + }, + "children": [ + { + "type": "block", + "id": "yJ1n0ocpfH", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[-509.7051213118268,-469.55707155770506,1924.0508676236536,1089.1141431154101]", + "index": "a0" + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "9t3Ah0wozZ", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-461.8167639829678,-479.74267557148,1390.0495489396078,383.28507166552515]", + "background": "--affine-palette-transparent", + "index": "a1", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 8, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.3939460292889514, + "collapse": true, + "collapsedHeight": 160.10597857102414 + } + }, + "children": [ + { + "type": "block", + "id": "NHDwZTS59L", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h1", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "LzI7P4hb4X", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-452.57487563328925,-183.9800061178195,871.3963546611783,235.3688641606808]", + "background": "--affine-palette-transparent", + "index": "a3", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 8, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.3939460292889514, + "collapse": true, + "collapsedHeight": 98.3183669477252 + } + }, + "children": [ + { + "type": "block", + "id": "pMlpAfbEoh", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h5", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "aHToM_8I3t", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[452.1184517179579,-183.9800061178195,871.3963546611783,235.3688641606808]", + "background": "--affine-palette-transparent", + "index": "a5", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 8, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 2.3939460292889514, + "collapse": true, + "collapsedHeight": 98.3183669477252 + } + }, + "children": [ + { + "type": "block", + "id": "xIuYaOWD8L", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h5", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.title" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "mZBqMOupoM", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[-428.74023932678745,-63.577752506957125,849.0831963889726,551.8234792085989]", + "background": "--affine-palette-transparent", + "index": "a6", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 8, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.4981539090092388, + "collapse": true, + "collapsedHeight": 368.33564021037836 + } + }, + "children": [ + { + "type": "block", + "id": "MbDU4z8Y4c", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h6", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section1.content" + } + ] + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "DPQCANC2uz", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[474.43160999016345,-63.577752506957125,849.0831963889726,551.8234792085989]", + "background": "--affine-palette-transparent", + "index": "a8", + "hidden": false, + "displayMode": "edgeless", + "edgeless": { + "style": { + "borderRadius": 8, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "" + }, + "scale": 1.4981539090092388, + "collapse": true, + "collapsedHeight": 368.33564021037836 + } + }, + "children": [ + { + "type": "block", + "id": "8jKa1dvzeC", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "h6", + "text": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "section2.content" + } + ] + } + }, + "children": [] + } + ] + } + ] + } + } +] diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/action-reporter.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/action-reporter.ts new file mode 100644 index 0000000000..28acbef29b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/action-reporter.ts @@ -0,0 +1,12 @@ +import { type ActionEventType, AIProvider } from '../provider.js'; + +export function reportResponse(event: ActionEventType) { + const lastAction = AIProvider.actionHistory.at(-1); + if (!lastAction) return; + + AIProvider.slots.actions.emit({ + action: lastAction.action, + options: lastAction.options, + event, + }); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/connector.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/connector.ts new file mode 100644 index 0000000000..d6ab52cf4e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/connector.ts @@ -0,0 +1,88 @@ +import { + type ConnectorElementModel, + type EdgelessRootService, + SurfaceBlockComponent, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; + +export const getConnectorFromId = ( + id: string, + surface: EdgelessRootService +) => { + return surface.elements.filter( + v => SurfaceBlockComponent.isConnector(v) && v.source.id === id + ) as ConnectorElementModel[]; +}; +export const getConnectorToId = (id: string, surface: EdgelessRootService) => { + return surface.elements.filter( + v => SurfaceBlockComponent.isConnector(v) && v.target.id === id + ) as ConnectorElementModel[]; +}; +export const getConnectorPath = (id: string, surface: EdgelessRootService) => { + let current: string | undefined = id; + const set = new Set(); + const result: string[] = []; + while (current) { + if (set.has(current)) { + return result; + } + set.add(current); + const connector = getConnectorToId(current, surface); + if (connector.length !== 1) { + return result; + } + current = connector[0].source.id; + if (current) { + result.unshift(current); + } + } + return result; +}; +type ElementTree = { + id: string; + children: ElementTree[]; +}; +export const findTree = ( + rootId: string, + surface: EdgelessRootService +): ElementTree => { + const set = new Set(); + const run = (id: string): ElementTree | undefined => { + if (set.has(id)) { + return; + } + set.add(id); + const children = getConnectorFromId(id, surface); + return { + id, + children: children.flatMap(model => { + const childId = model.target.id; + if (childId) { + const elementTree = run(childId); + if (elementTree) { + return [elementTree]; + } + } + return []; + }), + }; + }; + const tree = run(rootId); + assertExists(tree); + return tree; +}; +export const findLeaf = ( + tree: ElementTree, + id: string +): ElementTree | undefined => { + if (tree.id === id) { + return tree; + } + for (const child of tree.children) { + const result = findLeaf(child, id); + if (result) { + return result; + } + } + return; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/custom-specs.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/custom-specs.ts new file mode 100644 index 0000000000..2ea16c9842 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/custom-specs.ts @@ -0,0 +1,22 @@ +import { PageEditorBlockSpecs, PageRootService } from '@blocksuite/blocks'; +import { literal } from 'lit/static-html.js'; + +/** + * Custom PageRootService that does not load fonts + */ +class CustomPageRootService extends PageRootService { + override loadFonts() {} +} + +export const CustomPageEditorBlockSpecs = PageEditorBlockSpecs.map(spec => { + if (spec.schema.model.flavour === 'affine:page') { + return { + ...spec, + service: CustomPageRootService, + view: { + component: literal`affine-page-root`, + }, + }; + } + return spec; +}); diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/edgeless.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/edgeless.ts new file mode 100644 index 0000000000..ca3a74545b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/edgeless.ts @@ -0,0 +1,74 @@ +import type { BlockElement, EditorHost } from '@blocksuite/block-std'; +import { + AFFINE_EDGELESS_COPILOT_WIDGET, + type EdgelessCopilotWidget, + type EdgelessRootService, + matchFlavours, + MindmapElementModel, + type ShapeElementModel, +} from '@blocksuite/blocks'; + +export function mindMapToMarkdown(mindmap: MindmapElementModel) { + let markdownStr = ''; + + const traverse = ( + node: MindmapElementModel['tree']['children'][number], + indent: number = 0 + ) => { + const text = (node.element as ShapeElementModel).text?.toString() ?? ''; + + markdownStr += `${' '.repeat(indent)}- ${text}\n`; + + if (node.children) { + node.children.forEach(node => traverse(node, indent + 2)); + } + }; + + traverse(mindmap.tree, 0); + + return markdownStr; +} + +export function isMindMapRoot(ele: BlockSuite.EdgelessModelType) { + const group = ele?.group; + + return group instanceof MindmapElementModel && group.tree.element === ele; +} + +export function isMindmapChild(ele: BlockSuite.EdgelessModelType) { + return ele?.group instanceof MindmapElementModel && !isMindMapRoot(ele); +} + +export function getService(host: EditorHost) { + const edgelessService = host.spec.getService( + 'affine:page' + ) as EdgelessRootService; + + return edgelessService; +} + +export function getEdgelessCopilotWidget( + host: EditorHost +): EdgelessCopilotWidget { + const rootBlockId = host.doc.root?.id as string; + const copilotWidget = host.view.getWidget( + AFFINE_EDGELESS_COPILOT_WIDGET, + rootBlockId + ) as EdgelessCopilotWidget; + + return copilotWidget; +} + +export function findNoteBlockModel(blockElement: BlockElement) { + let curBlock = blockElement; + while (curBlock) { + if (matchFlavours(curBlock.model, ['affine:note'])) { + return curBlock.model; + } + if (matchFlavours(curBlock.model, ['affine:page', 'affine:surface'])) { + return null; + } + curBlock = curBlock.parentBlockElement; + } + return null; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts new file mode 100644 index 0000000000..9dc1748d1c --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/editor-actions.ts @@ -0,0 +1,148 @@ +import type { + BlockElement, + EditorHost, + TextSelection, +} from '@blocksuite/block-std'; +import type { AffineAIPanelWidget } from '@blocksuite/blocks'; +import { isInsideEdgelessEditor } from '@blocksuite/blocks'; +import { type BlockModel, Slice } from '@blocksuite/store'; + +import { + insertFromMarkdown, + markDownToDoc, + markdownToSnapshot, +} from './markdown-utils.js'; + +const getNoteId = (blockElement: BlockElement) => { + let element = blockElement; + while (element && element.flavour !== 'affine:note') { + element = element.parentBlockElement; + } + + return element.model.id; +}; + +const setBlockSelection = ( + host: EditorHost, + parent: BlockElement, + models: BlockModel[] +) => { + const selections = models + .map(model => model.id) + .map(blockId => host.selection.create('block', { blockId })); + + if (isInsideEdgelessEditor(host)) { + const surfaceElementId = getNoteId(parent); + const surfaceSelection = host.selection.create( + 'surface', + selections[0].blockId, + [surfaceElementId], + true + ); + + selections.push(surfaceSelection); + host.selection.set(selections); + } else { + host.selection.setGroup('note', selections); + } +}; + +export const insert = async ( + host: EditorHost, + content: string, + selectBlock: BlockElement, + below: boolean = true +) => { + const blockParent = selectBlock.parentBlockElement; + const index = blockParent.model.children.findIndex( + model => model.id === selectBlock.model.id + ); + const insertIndex = below ? index + 1 : index; + + const models = await insertFromMarkdown( + host, + content, + blockParent.model.id, + insertIndex + ); + await host.updateComplete; + requestAnimationFrame(() => setBlockSelection(host, blockParent, models)); +}; + +export const insertBelow = async ( + host: EditorHost, + content: string, + selectBlock: BlockElement +) => { + await insert(host, content, selectBlock, true); +}; + +export const insertAbove = async ( + host: EditorHost, + content: string, + selectBlock: BlockElement +) => { + await insert(host, content, selectBlock, false); +}; + +export const replace = async ( + host: EditorHost, + content: string, + firstBlock: BlockElement, + selectedModels: BlockModel[], + textSelection?: TextSelection +) => { + const firstBlockParent = firstBlock.parentBlockElement; + const firstIndex = firstBlockParent.model.children.findIndex( + model => model.id === firstBlock.model.id + ); + + if (textSelection) { + const { snapshot, job } = await markdownToSnapshot(content, host); + await job.snapshotToSlice( + snapshot, + host.doc, + firstBlockParent.model.id, + firstIndex + 1 + ); + } else { + selectedModels.forEach(model => { + host.doc.deleteBlock(model); + }); + + const models = await insertFromMarkdown( + host, + content, + firstBlockParent.model.id, + firstIndex + ); + + await host.updateComplete; + requestAnimationFrame(() => + setBlockSelection(host, firstBlockParent, models) + ); + } +}; + +export const copyTextAnswer = async (panel: AffineAIPanelWidget) => { + const host = panel.host; + if (!panel.answer) { + return false; + } + return copyText(host, panel.answer); +}; + +export const copyText = async (host: EditorHost, text: string) => { + const previewDoc = await markDownToDoc(host, text); + const models = previewDoc + .getBlocksByFlavour('affine:note') + .map(b => b.model) + .flatMap(model => model.children); + const slice = Slice.fromModels(previewDoc, models); + await host.std.clipboard.copySlice(slice); + const { notificationService } = host.std.spec.getService('affine:page'); + if (notificationService) { + notificationService.toast('Copied to clipboard'); + } + return true; +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/html.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/html.ts new file mode 100644 index 0000000000..5440b8b968 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/html.ts @@ -0,0 +1,5 @@ +export function preprocessHtml(answer: string) { + const start = answer.indexOf(''); + const end = answer.indexOf(''); + return answer.slice(start, end + ''.length); +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/image.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/image.ts new file mode 100644 index 0000000000..6c21c98a22 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/image.ts @@ -0,0 +1,104 @@ +import { fetchImage } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; + +export async function fetchImageToFile( + url: string, + filename: string, + imageProxy?: string +): Promise { + try { + const res = await fetchImage(url, undefined, imageProxy); + if (res.ok) { + let blob = await res.blob(); + if (!blob.type || !blob.type.startsWith('image/')) { + blob = await convertToPng(blob).then(tmp => tmp || blob); + } + return new File([blob], filename, { type: blob.type || 'image/png' }); + } + } catch (err) { + console.error(err); + } + + return fetchImageFallback(url, filename); +} + +function fetchImageFallback( + url: string, + filename: string +): Promise { + return new Promise(resolve => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.width; + c.height = img.height; + const ctx = c.getContext('2d'); + assertExists(ctx); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0); + c.toBlob(blob => { + if (blob) { + return resolve(new File([blob], filename, { type: blob.type })); + } + resolve(); + }, 'image/png'); + }; + img.onerror = () => resolve(); + img.crossOrigin = 'anonymous'; + img.src = url; + }); +} + +function convertToPng(blob: Blob): Promise { + return new Promise(resolve => { + const reader = new FileReader(); + reader.addEventListener('load', _ => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.width; + c.height = img.height; + const ctx = c.getContext('2d'); + assertExists(ctx); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0); + c.toBlob(resolve, 'image/png'); + }; + img.onerror = () => resolve(null); + img.src = reader.result as string; + }); + reader.addEventListener('error', () => resolve(null)); + reader.readAsDataURL(blob); + }); +} + +export function readBlobAsURL(blob: Blob | File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => { + if (typeof e.target?.result === 'string') { + resolve(e.target.result); + } else { + reject(); + } + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +export function canvasToBlob( + canvas: HTMLCanvasElement, + type = 'image/png', + quality?: number +) { + return new Promise(resolve => + canvas.toBlob(resolve, type, quality) + ); +} + +export function randomSeed(min = 0, max = Date.now()) { + return Math.round(Math.random() * (max - min)) + min; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts new file mode 100644 index 0000000000..7bfc3951ad --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/markdown-utils.ts @@ -0,0 +1,195 @@ +import type { + EditorHost, + TextRangePoint, + TextSelection, +} from '@blocksuite/block-std'; +import { + defaultImageProxyMiddleware, + MarkdownAdapter, + MixTextAdapter, + pasteMiddleware, + PlainTextAdapter, + titleMiddleware, +} from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import type { + BlockModel, + BlockSnapshot, + Doc, + DraftModel, + Slice, + SliceSnapshot, +} from '@blocksuite/store'; +import { DocCollection, Job } from '@blocksuite/store'; + +const updateSnapshotText = ( + point: TextRangePoint, + snapshot: BlockSnapshot, + model: DraftModel +) => { + const { index, length } = point; + if (!snapshot.props.text || length === 0) { + return; + } + (snapshot.props.text as Record).delta = + model.text?.sliceToDelta(index, length + index); +}; + +function processSnapshot( + snapshot: BlockSnapshot, + text: TextSelection, + host: EditorHost +) { + const model = host.doc.getBlockById(snapshot.id); + assertExists(model); + + const modelId = model.id; + if (text.from.blockId === modelId) { + updateSnapshotText(text.from, snapshot, model); + } + if (text.to && text.to.blockId === modelId) { + updateSnapshotText(text.to, snapshot, model); + } + + // If the snapshot has children, handle them recursively + snapshot.children.forEach(childSnapshot => + processSnapshot(childSnapshot, text, host) + ); +} + +/** + * Processes the text in the given snapshot if there is a text selection. + * Only the selected portion of the snapshot will be processed. + */ +function processTextInSnapshot(snapshot: SliceSnapshot, host: EditorHost) { + const { content } = snapshot; + const text = host.selection.find('text'); + if (!content.length || !text) return; + + content.forEach(snapshot => processSnapshot(snapshot, text, host)); +} + +export async function getContentFromSlice( + host: EditorHost, + slice: Slice, + type: 'markdown' | 'plain-text' = 'markdown' +) { + const job = new Job({ + collection: host.std.doc.collection, + middlewares: [titleMiddleware], + }); + const snapshot = await job.sliceToSnapshot(slice); + processTextInSnapshot(snapshot, host); + const adapter = + type === 'markdown' ? new MarkdownAdapter(job) : new PlainTextAdapter(job); + const content = await adapter.fromSliceSnapshot({ + snapshot, + assets: job.assetsManager, + }); + return content.file; +} + +export async function getPlainTextFromSlice(host: EditorHost, slice: Slice) { + const job = new Job({ + collection: host.std.doc.collection, + middlewares: [titleMiddleware], + }); + const snapshot = await job.sliceToSnapshot(slice); + processTextInSnapshot(snapshot, host); + const plainTextAdapter = new PlainTextAdapter(job); + const plainText = await plainTextAdapter.fromSliceSnapshot({ + snapshot, + assets: job.assetsManager, + }); + return plainText.file; +} + +export const markdownToSnapshot = async ( + markdown: string, + host: EditorHost +) => { + const job = new Job({ + collection: host.std.doc.collection, + middlewares: [defaultImageProxyMiddleware, pasteMiddleware(host.std)], + }); + const markdownAdapter = new MixTextAdapter(job); + const { blockVersions, workspaceVersion, pageVersion } = + host.std.doc.collection.meta; + if (!blockVersions || !workspaceVersion || !pageVersion) + throw new Error( + 'Need blockVersions, workspaceVersion, pageVersion meta information to get slice' + ); + const payload = { + file: markdown, + assets: job.assetsManager, + blockVersions, + pageVersion, + workspaceVersion, + workspaceId: host.std.doc.collection.id, + pageId: host.std.doc.id, + }; + + const snapshot = await markdownAdapter.toSliceSnapshot(payload); + assertExists(snapshot, 'import markdown failed, expected to get a snapshot'); + + return { + snapshot, + job, + }; +}; + +export async function insertFromMarkdown( + host: EditorHost, + markdown: string, + parent?: string, + index?: number +) { + const { snapshot, job } = await markdownToSnapshot(markdown, host); + + const snapshots = snapshot.content.flatMap(x => x.children); + + const models: BlockModel[] = []; + for (let i = 0; i < snapshots.length; i++) { + const blockSnapshot = snapshots[i]; + const model = await job.snapshotToBlock( + blockSnapshot, + host.std.doc, + parent, + (index ?? 0) + i + ); + models.push(model); + } + + return models; +} + +// FIXME: replace when selection is block is buggy right not +export async function replaceFromMarkdown( + host: EditorHost, + markdown: string, + parent?: string, + index?: number +) { + const { snapshot, job } = await markdownToSnapshot(markdown, host); + await job.snapshotToSlice(snapshot, host.doc, parent, index); +} + +export async function markDownToDoc(host: EditorHost, answer: string) { + const schema = host.std.doc.collection.schema; + // Should not create a new doc in the original collection + const collection = new DocCollection({ schema }); + collection.meta.initialize(); + const job = new Job({ + collection, + middlewares: [defaultImageProxyMiddleware], + }); + const mdAdapter = new MarkdownAdapter(job); + const doc = await mdAdapter.toDoc({ + file: answer, + assets: job.assetsManager, + }); + if (!doc) { + console.error('Failed to convert markdown to doc'); + } + return doc as Doc; +} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/utils/selection-utils.ts b/packages/frontend/core/src/blocksuite/presets/ai/utils/selection-utils.ts new file mode 100644 index 0000000000..7a2239562e --- /dev/null +++ b/packages/frontend/core/src/blocksuite/presets/ai/utils/selection-utils.ts @@ -0,0 +1,300 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { + type CopilotSelectionController, + type FrameBlockModel, + ImageBlockModel, + type SurfaceBlockComponent, +} from '@blocksuite/blocks'; +import { BlocksUtils, EdgelessRootService } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import { + type BlockModel, + type DraftModel, + Slice, + toDraftModel, +} from '@blocksuite/store'; + +import { getEdgelessCopilotWidget, getService } from './edgeless.js'; +import { getContentFromSlice } from './markdown-utils.js'; + +export const getRootService = (host: EditorHost) => { + return host.std.spec.getService('affine:page'); +}; + +export function getEdgelessRootFromEditor(editor: EditorHost) { + const edgelessRoot = editor.getElementsByTagName('affine-edgeless-root')[0]; + if (!edgelessRoot) { + alert('Please switch to edgeless mode'); + throw new Error('Please open switch to edgeless mode'); + } + return edgelessRoot; +} +export function getEdgelessService(editor: EditorHost) { + const rootService = editor.std.spec.getService('affine:page'); + if (rootService instanceof EdgelessRootService) { + return rootService; + } + alert('Please switch to edgeless mode'); + throw new Error('Please open switch to edgeless mode'); +} + +export async function selectedToCanvas(editor: EditorHost) { + const edgelessRoot = getEdgelessRootFromEditor(editor); + const { notes, frames, shapes, images } = BlocksUtils.splitElements( + edgelessRoot.service.selection.selectedElements + ); + if (notes.length + frames.length + images.length + shapes.length === 0) { + return; + } + const canvas = await edgelessRoot.clipboardController.toCanvas( + [...notes, ...frames, ...images], + shapes + ); + if (!canvas) { + return; + } + return canvas; +} + +export async function frameToCanvas( + frame: FrameBlockModel, + editor: EditorHost +) { + const edgelessRoot = getEdgelessRootFromEditor(editor); + const { notes, frames, shapes, images } = BlocksUtils.splitElements( + edgelessRoot.service.frame.getElementsInFrame(frame, true) + ); + if (notes.length + frames.length + images.length + shapes.length === 0) { + return; + } + const canvas = await edgelessRoot.clipboardController.toCanvas( + [...notes, ...frames, ...images], + shapes + ); + if (!canvas) { + return; + } + return canvas; +} + +export async function selectedToPng(editor: EditorHost) { + return (await selectedToCanvas(editor))?.toDataURL('image/png'); +} + +export function getSelectedModels(editorHost: EditorHost) { + const chain = editorHost.std.command.chain(); + const [_, ctx] = chain + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + return selectedModels; +} + +function traverse(model: DraftModel, drafts: DraftModel[]) { + const isDatabase = model.flavour === 'affine:database'; + const children = isDatabase + ? model.children + : model.children.filter(child => { + const idx = drafts.findIndex(m => m.id === child.id); + return idx >= 0; + }); + + children.forEach(child => { + const idx = drafts.findIndex(m => m.id === child.id); + if (idx >= 0) { + drafts.splice(idx, 1); + } + traverse(child, drafts); + }); + model.children = children; +} + +export async function getTextContentFromBlockModels( + editorHost: EditorHost, + models: BlockModel[], + type: 'markdown' | 'plain-text' = 'markdown' +) { + // Currently only filter out images and databases + const selectedTextModels = models.filter( + model => + !BlocksUtils.matchFlavours(model, ['affine:image', 'affine:database']) + ); + const drafts = selectedTextModels.map(toDraftModel); + drafts.forEach(draft => traverse(draft, drafts)); + const slice = Slice.fromModels(editorHost.std.doc, drafts); + return getContentFromSlice(editorHost, slice, type); +} + +export async function getSelectedTextContent( + editorHost: EditorHost, + type: 'markdown' | 'plain-text' = 'markdown' +) { + const selectedModels = getSelectedModels(editorHost); + assertExists(selectedModels); + return getTextContentFromBlockModels(editorHost, selectedModels, type); +} + +export async function selectAboveBlocks(editorHost: EditorHost, num = 10) { + let selectedModels = getSelectedModels(editorHost); + assertExists(selectedModels); + + const lastLeafModel = selectedModels[selectedModels.length - 1]; + + let noteModel: BlockModel | null = lastLeafModel; + let lastRootModel: BlockModel | null = null; + while (noteModel && noteModel.flavour !== 'affine:note') { + lastRootModel = noteModel; + noteModel = editorHost.doc.getParent(noteModel); + } + assertExists(noteModel); + assertExists(lastRootModel); + + const endIndex = noteModel.children.indexOf(lastRootModel) + 1; + const startIndex = Math.max(0, endIndex - num); + const startBlock = noteModel.children[startIndex]; + + selectedModels = []; + let stop = false; + const traverse = (model: BlockModel): void => { + if (stop) return; + + selectedModels.push(model); + + if (model === lastLeafModel) { + stop = true; + return; + } + + model.children.forEach(child => traverse(child)); + }; + noteModel.children.slice(startIndex, endIndex).forEach(traverse); + + const { selection } = editorHost; + selection.set([ + selection.create('text', { + from: { + blockId: startBlock.id, + index: 0, + length: startBlock.text?.length ?? 0, + }, + to: { + blockId: lastLeafModel.id, + index: 0, + length: selection.find('text')?.from.index ?? 0, + }, + }), + ]); + + return getTextContentFromBlockModels(editorHost, selectedModels); +} + +export const stopPropagation = (e: Event) => { + e.stopPropagation(); +}; + +export function getSurfaceElementFromEditor(editor: EditorHost) { + const { doc } = editor; + const surfaceModel = doc.getBlockByFlavour('affine:surface')[0]; + assertExists(surfaceModel); + + const surfaceId = surfaceModel.id; + const surfaceElement = editor.querySelector( + `affine-surface[data-block-id="${surfaceId}"]` + ) as SurfaceBlockComponent; + assertExists(surfaceElement); + + return surfaceElement; +} + +export const getFirstImageInFrame = ( + frame: FrameBlockModel, + editor: EditorHost +) => { + const edgelessRoot = getEdgelessRootFromEditor(editor); + const elements = edgelessRoot.service.frame.getElementsInFrame(frame, false); + const image = elements.find(ele => { + if (!BlocksUtils.isCanvasElement(ele)) { + return ele.flavour === 'affine:image'; + } + return false; + }) as ImageBlockModel | undefined; + return image?.id; +}; + +export const getSelections = ( + host: EditorHost, + mode: 'flat' | 'highest' = 'flat' +) => { + const [_, data] = host.command + .chain() + .tryAll(chain => [ + chain.getTextSelection(), + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedBlocks({ types: ['text', 'block', 'image'], mode }) + .run(); + + return data; +}; + +export const getSelectedImagesAsBlobs = async (host: EditorHost) => { + const [_, data] = host.command + .chain() + .tryAll(chain => [ + chain.getTextSelection(), + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedBlocks({ + types: ['image'], + }) + .run(); + + const blobs = await Promise.all( + data.selectedBlocks?.map(async b => { + const sourceId = (b.model as ImageBlockModel).sourceId; + if (!sourceId) return null; + const blob = await host.doc.blobSync.get(sourceId); + if (!blob) return null; + return new File([blob], sourceId); + }) ?? [] + ); + return blobs.filter((blob): blob is File => !!blob); +}; + +export const getSelectedNoteAnchor = (host: EditorHost, id: string) => { + return host.querySelector(`[data-portal-block-id="${id}"] .note-background`); +}; + +export function getCopilotSelectedElems( + host: EditorHost +): BlockSuite.EdgelessModelType[] { + const service = getService(host); + const copilotWidget = getEdgelessCopilotWidget(host); + + if (copilotWidget.visible) { + return (service.tool.controllers['copilot'] as CopilotSelectionController) + .selectedElements; + } + + return service.selection.selectedElements; +} + +export const imageCustomInput = async (host: EditorHost) => { + const selectedElements = getCopilotSelectedElems(host); + if (selectedElements.length !== 1) return; + + const imageBlock = selectedElements[0]; + if (!(imageBlock instanceof ImageBlockModel)) return; + if (!imageBlock.sourceId) return; + + const blob = await host.doc.blobSync.get(imageBlock.sourceId); + if (!blob) return; + + return { + attachments: [blob], + }; +}; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts index 086441d4f4..4d4d60d502 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/request.ts @@ -1,5 +1,5 @@ +import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { assertExists } from '@blocksuite/global/utils'; -import { AIProvider } from '@blocksuite/presets/ai'; import { partition } from 'lodash-es'; import { CopilotClient } from './copilot-client'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx index 3e73ab3761..7f6732e0b7 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx @@ -1,11 +1,11 @@ import { notify } from '@affine/component'; import { authAtom, openSettingModalAtom } from '@affine/core/atoms'; +import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { mixpanel } from '@affine/core/utils'; import { getBaseUrl } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { UnauthorizedError } from '@blocksuite/blocks'; import { assertExists } from '@blocksuite/global/utils'; -import { AIProvider } from '@blocksuite/presets/ai'; import { getCurrentStore } from '@toeverything/infra'; import type { PromptKey } from './prompt'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts index 77fcf1ff80..1ba577ad4e 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/tracker.ts @@ -1,6 +1,6 @@ +import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { mixpanel } from '@affine/core/utils'; import type { EditorHost } from '@blocksuite/block-std'; -import { AIProvider } from '@blocksuite/presets/ai'; import type { BlockModel } from '@blocksuite/store'; import { lowerCase, omit } from 'lodash-es'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts index aea5e32cc8..ce7c9e12aa 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts @@ -1,3 +1,8 @@ +import { + AICodeBlockSpec, + AIImageBlockSpec, + AIParagraphBlockSpec, +} from '@affine/core/blocksuite/presets/ai'; import type { BlockSpec } from '@blocksuite/block-std'; import { BookmarkBlockSpec, @@ -14,11 +19,6 @@ import { ListBlockSpec, NoteBlockSpec, } from '@blocksuite/blocks'; -import { - AICodeBlockSpec, - AIImageBlockSpec, - AIParagraphBlockSpec, -} from '@blocksuite/presets/ai'; import { CustomAttachmentBlockSpec } from './custom/attachment-block'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts index 7bb4acee51..92b92e8437 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts @@ -1,3 +1,7 @@ +import { + AIEdgelessRootBlockSpec, + AIPageRootBlockSpec, +} from '@affine/core/blocksuite/presets/ai'; import { mixpanel } from '@affine/core/utils'; import type { BlockSpec } from '@blocksuite/block-std'; import type { RootService, TelemetryEventMap } from '@blocksuite/blocks'; @@ -6,10 +10,6 @@ import { EdgelessRootService, PageRootService, } from '@blocksuite/blocks'; -import { - AIEdgelessRootBlockSpec, - AIPageRootBlockSpec, -} from '@blocksuite/presets/ai'; function customLoadFonts(service: RootService): void { if (runtimeConfig.isSelfHosted) { diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index 3a2097f681..594ae918ce 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -1,6 +1,6 @@ +import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { apis } from '@affine/electron-api'; import type { OAuthProviderType } from '@affine/graphql'; -import { AIProvider } from '@blocksuite/presets/ai'; import { ApplicationFocused, ApplicationStarted, diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.tsx index 40c5030b11..bfeaedb553 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/multi-tabs/tabs/chat.tsx @@ -1,6 +1,6 @@ +import { ChatPanel } from '@affine/core/blocksuite/presets/ai'; import { assertExists } from '@blocksuite/global/utils'; import { AiIcon } from '@blocksuite/icons'; -import { ChatPanel } from '@blocksuite/presets/ai'; import { useCallback, useEffect, useRef } from 'react'; import type { SidebarTab, SidebarTabProps } from '../sidebar-tab'; diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx index 788fa4aff8..0ccc777bf3 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx @@ -1,13 +1,13 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; +import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { PageNotFound } from '@affine/core/pages/404'; import { Bound, type EdgelessRootService } from '@blocksuite/blocks'; import { DisposableGroup } from '@blocksuite/global/utils'; -import { type AffineEditorContainer } from '@blocksuite/presets'; -import { AIProvider } from '@blocksuite/presets/ai'; +import type { AffineEditorContainer } from '@blocksuite/presets'; import type { DocMode } from '@toeverything/infra'; import { DocsService, FrameworkScope, useService } from '@toeverything/infra'; import { diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 5485eb6a66..cc9667b0d2 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -1,5 +1,6 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; +import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding'; import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { RecentPagesService } from '@affine/core/modules/cmdk'; @@ -14,7 +15,6 @@ import { } from '@blocksuite/blocks'; import { DisposableGroup } from '@blocksuite/global/utils'; import { type AffineEditorContainer } from '@blocksuite/presets'; -import { AIProvider } from '@blocksuite/presets/ai'; import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; import type { Doc } from '@toeverything/infra'; import { diff --git a/packages/frontend/electron/package.json b/packages/frontend/electron/package.json index 386ed5a168..096445fa4b 100644 --- a/packages/frontend/electron/package.json +++ b/packages/frontend/electron/package.json @@ -95,4 +95,4 @@ "peerDependencies": { "ts-node": "*" } -} +} \ No newline at end of file diff --git a/tools/cli/package.json b/tools/cli/package.json index 8e248866b3..0f99b321fc 100644 --- a/tools/cli/package.json +++ b/tools/cli/package.json @@ -46,4 +46,4 @@ "dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts" }, "version": "0.14.0" -} +} \ No newline at end of file diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts index 33db290f28..33334a5831 100644 --- a/tools/cli/src/webpack/config.ts +++ b/tools/cli/src/webpack/config.ts @@ -152,6 +152,7 @@ export const createConfiguration: ( }, alias: { yjs: require.resolve('yjs'), + lit: join(workspaceRoot, 'node_modules', 'lit'), '@blocksuite/block-std': blocksuiteBaseDir ? join(blocksuiteBaseDir, 'packages', 'framework', 'block-std', 'src') : join( diff --git a/vitest.config.ts b/vitest.config.ts index 8c99dab88a..33dfd01849 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,18 +2,38 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; -import react from '@vitejs/plugin-react-swc'; import * as fg from 'fast-glob'; +import swc from 'unplugin-swc'; import { defineConfig } from 'vitest/config'; const rootDir = fileURLToPath(new URL('.', import.meta.url)); export default defineConfig({ plugins: [ - react({ - tsDecorators: true, - }), vanillaExtractPlugin(), + // https://github.com/vitejs/vite-plugin-react-swc/issues/85#issuecomment-2003922124 + swc.vite({ + jsc: { + preserveAllComments: true, + parser: { + syntax: 'typescript', + dynamicImport: true, + tsx: true, + decorators: true, + }, + target: 'es2022', + externalHelpers: false, + transform: { + react: { + runtime: 'automatic', + }, + useDefineForClassFields: false, + decoratorVersion: '2022-03', + }, + }, + sourceMaps: true, + inlineSourcesContent: true, + }), ], assetsInclude: ['**/*.md', '**/*.zip'], resolve: { diff --git a/yarn.lock b/yarn.lock index 29cdfe2f40..a4c795e4c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -394,10 +394,12 @@ __metadata: "@dnd-kit/modifiers": "npm:^7.0.0" "@dnd-kit/sortable": "npm:^8.0.0" "@dnd-kit/utilities": "npm:^3.2.2" + "@dotlottie/player-component": "npm:^2.7.12" "@emotion/cache": "npm:^11.11.0" "@emotion/react": "npm:^11.11.4" "@emotion/server": "npm:^11.11.0" "@emotion/styled": "npm:^11.11.5" + "@floating-ui/dom": "npm:^1.6.5" "@juggle/resize-observer": "npm:^3.4.0" "@marsidev/react-turnstile": "npm:^0.7.0" "@perfsee/webpack": "npm:^1.12.2" @@ -444,7 +446,7 @@ __metadata: jotai-devtools: "npm:^0.10.0" jotai-effect: "npm:^1.0.0" jotai-scope: "npm:^0.6.0" - lit: "npm:^3.1.2" + lit: "npm:^3.1.3" lodash-es: "npm:^4.17.21" lottie-react: "npm:^2.4.0" lottie-web: "npm:^5.12.2" @@ -672,6 +674,7 @@ __metadata: string-width: "npm:^7.1.0" ts-node: "npm:^10.9.2" typescript: "npm:^5.4.5" + unplugin-swc: "npm:^1.4.5" vite: "npm:^5.2.8" vite-plugin-istanbul: "npm:^6.0.0" vite-plugin-static-copy: "npm:^1.0.2" @@ -27508,6 +27511,13 @@ __metadata: languageName: node linkType: hard +"load-tsconfig@npm:^0.2.5": + version: 0.2.5 + resolution: "load-tsconfig@npm:0.2.5" + checksum: 10/b3176f6f0c86dbdbbc7e337440a803b0b4407c55e2e1cfc53bd3db68e0211448f36428a6075ecf5e286db5d1bf791da756fc0ac4d2447717140fb6a5218ecfb4 + languageName: node + linkType: hard + "loader-runner@npm:^4.1.0, loader-runner@npm:^4.2.0": version: 4.3.0 resolution: "loader-runner@npm:4.3.0" @@ -37043,6 +37053,19 @@ __metadata: languageName: node linkType: hard +"unplugin-swc@npm:^1.4.5": + version: 1.4.5 + resolution: "unplugin-swc@npm:1.4.5" + dependencies: + "@rollup/pluginutils": "npm:^5.1.0" + load-tsconfig: "npm:^0.2.5" + unplugin: "npm:^1.10.1" + peerDependencies: + "@swc/core": ^1.2.108 + checksum: 10/93ea6ef83f131730a156d41d7180741b18203a3c815200040b77a4395d13f591206a23a01001e561e445846e5f361b8bffd50c78962f9349d10f24fa4905fe13 + languageName: node + linkType: hard + "unplugin@npm:1.0.1": version: 1.0.1 resolution: "unplugin@npm:1.0.1" @@ -37055,7 +37078,7 @@ __metadata: languageName: node linkType: hard -"unplugin@npm:^1.3.1": +"unplugin@npm:^1.10.1, unplugin@npm:^1.3.1": version: 1.10.1 resolution: "unplugin@npm:1.10.1" dependencies: