refactor(core): migrate ai preset to AFFiNE (#7219)

## TL;DR
Move `@blocksuite/presets/ai` to AFFiNE. After this PR is merged, you can use AI features from `@affine/core/blocksuite/presets/ai`.
This commit is contained in:
L-Sun
2024-06-18 07:31:06 +00:00
parent 0fe672efa5
commit b3ec3a2b3e
84 changed files with 17228 additions and 30 deletions
+1
View File
@@ -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",
+1 -1
View File
@@ -27,4 +27,4 @@
"zod": "^3.22.4"
},
"version": "0.14.0"
}
}
+1 -1
View File
@@ -68,4 +68,4 @@
}
},
"version": "0.14.0"
}
}
+1 -1
View File
@@ -109,4 +109,4 @@
"yjs": "^13.6.14"
},
"version": "0.14.0"
}
}
+4 -2
View File
@@ -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"
}
}
}
@@ -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<buttonSize, string> = {
small: '72px',
middle: '76px',
large: '82px',
};
const buttonHeightMap: Record<buttonSize, string> = {
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`<ask-ai-panel
.host=${this.host}
.actionGroups=${this.actionGroups}
.abortController=${abortController}
></ask-ai-panel>`,
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`<ask-ai-panel
.host=${this.host}
.actionGroups=${this.actionGroups}
.minWidth=${panelMinWidth}
></ask-ai-panel>`,
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`<div
class="ask-ai-button"
style=${buttonStyles}
${toggleType === 'hover' ? ref(this._whenHover.setReference) : nothing}
@click=${this._toggleAIPanel}
>
<icon-button
class="ask-ai-icon-button ${size}"
width=${buttonWidthMap[size]}
height=${buttonHeightMap[size]}
>
${AIStarIcon} <span>Ask AI</span></icon-button
>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ask-ai-button': AskAIButton;
}
}
@@ -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`<div class="ask-ai-panel" style=${style}>
<ai-item-list
.host=${this.host}
.groups=${this._actionGroups}
></ai-item-list>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ask-ai-panel': AskAIPanel;
}
}
@@ -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<InitCommandCtx>) => {
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<InitCommandCtx>) => {
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<InitCommandCtx>) => {
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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
generatingIcon: TemplateResult<1>,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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,
];
}
File diff suppressed because one or more lines are too long
@@ -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;
}
@@ -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;
};
@@ -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<BlockSuitePresets.AIActions>]: 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'];
@@ -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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
signal?: AbortSignal,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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<typeof action>[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<BlockSuitePresets.AIActions[T]>[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<T extends keyof BlockSuitePresets.AIActions>(
aiPanel: AffineAIPanelWidget,
id: T,
generatingIcon: TemplateResult<1>,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
generatingIcon: TemplateResult<1>,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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);
}
@@ -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<T extends keyof BlockSuitePresets.AIActions>(
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, K extends keyof T> = T & {
[P in K]-?: Exclude<T[P], undefined>;
};
function isShapeWithText(
el: ShapeElementModel
): el is RemoveUndefinedKey<ShapeElementModel, 'text'> {
return el.text !== undefined && el.text.length !== 0;
}
function isImageWithCaption(
el: ImageBlockModel
): el is RemoveUndefinedKey<ImageBlockModel, 'caption'> {
return el.caption !== undefined && el.caption.length !== 0;
}
const { notes, texts, shapes, images, edgelessTexts } = selected.reduce<{
notes: NoteBlockModel[];
texts: TextElementModel[];
shapes: RemoveUndefinedKey<ShapeElementModel, 'text'>[];
images: RemoveUndefinedKey<ImageBlockModel, 'caption'>[];
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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
signal?: AbortSignal,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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<typeof action>[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<typeof action>[0];
// @ts-expect-error todo: maybe fix this
stream = action(options);
if (!stream) return;
yield* stream;
},
};
};
}
function actionToGeneration<T extends keyof BlockSuitePresets.AIActions>(
id: T,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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<BlockSuitePresets.AIActions[T]>[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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
generatingIcon: TemplateResult<1>,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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<string, unknown> = {};
const selectedElements = getCopilotSelectedElems(host);
const { selectedBlocks } = getSelections(host);
const ctx = {
get() {
return {
...internal,
selectedElements,
};
},
set(data: Record<string, unknown>) {
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]);
}
@@ -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<T extends keyof BlockSuitePresets.AIActions>(
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<T extends keyof BlockSuitePresets.AIActions>(
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<BlockSuitePresets.AIActions>]: (
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<EdgelessRootService>('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<BlockSuitePresets.AIActions>]: (
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[key]>[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<BlockSuitePresets.AIActions[T]>[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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
host: EditorHost,
ctx: CtxRecord,
variants?: Omit<
Parameters<BlockSuitePresets.AIActions[T]>[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<T extends keyof BlockSuitePresets.AIActions>(
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<BlockSuitePresets.AIActions[T]>[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)),
],
},
],
};
}
@@ -0,0 +1,2 @@
export * from './doc-handler.js';
export * from './types.js';
@@ -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<string, unknown>;
set(data: Record<string, unknown>): 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<string>;
};
type AIActionTextResponse<T extends AITextActionOptions> =
T['stream'] extends true ? TextStream : Promise<string>;
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<T extends AITextActionOptions>(options: T): AIActionTextResponse<T>;
summary<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
improveWriting<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
improveGrammar<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
fixSpelling<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
createHeadings<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
makeLonger<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
makeShorter<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
continueWriting<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
checkCodeErrors<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
explainCode<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
writeArticle<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
writeTwitterPost<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
writePoem<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
writeBlogPost<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
brainstorm<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
writeOutline<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
explainImage<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
findActions<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
// mindmap
brainstormMindmap<T extends BrainstormMindMap>(
options: T
): AIActionTextResponse<T>;
expandMindmap<T extends ExpandMindMap>(
options: T
): AIActionTextResponse<T>;
// presentation
createSlides<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
// explain this
explain<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
// actions with variants
translate<T extends TranslateOptions>(
options: T
): AIActionTextResponse<T>;
changeTone<T extends ChangeToneOptions>(
options: T
): AIActionTextResponse<T>;
// make it real, image to text
makeItReal<T extends AIImageActionOptions>(
options: T
): AIActionTextResponse<T>;
createImage<T extends AIImageActionOptions>(
options: T
): AIActionTextResponse<T>;
processImage<T extends ProcessImageOptions>(
options: T
): AIActionTextResponse<T>;
filterImage<T extends FilterImageOptions>(
options: T
): AIActionTextResponse<T>;
generateCaption<T extends AITextActionOptions>(
options: T
): AIActionTextResponse<T>;
}
// 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<AIHistory[] | undefined>;
chats: (
workspaceId: string,
docId?: string
) => Promise<AIHistory[] | undefined>;
cleanup: (
workspaceId: string,
docId: string,
sessionIds: string[]
) => Promise<void>;
}
interface AIPhotoEngineService {
searchImages(options: {
width: number;
height: number;
query: string;
}): Promise<string[]>;
}
}
}
@@ -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<T extends keyof BlockSuitePresets.AIActions>(
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<T extends keyof BlockSuitePresets.AIActions>(
panel: AffineAIPanelWidget,
id?: T
) {
return {
responses: buildTextResponseConfig(panel, id),
actions: [],
};
}
export function buildErrorConfig<T extends keyof BlockSuitePresets.AIActions>(
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;
};
@@ -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);
}
});
},
};
@@ -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<string, TemplateResult<1>> = {
'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`<style></style>
<slot></slot>
<div
class="action-name"
@click=${() => (this.promptShow = !this.promptShow)}
>
${icons[item.action] ? icons[item.action] : ActionIcon}
<div>
<div>${item.action}</div>
<div>${this.promptShow ? ArrowDownIcon : ArrowUpIcon}</div>
</div>
</div>
${this.promptShow
? html`
<div class="answer-prompt">
<div class="subtitle">Answer</div>
${HISTORY_IMAGE_ACTIONS.includes(item.action)
? images && renderImages(images)
: nothing}
${answer
? createTextRenderer(this.host, { customHeading: true })(answer)
: nothing}
${originalText
? html`<div class="subtitle prompt">Prompt</div>
${createTextRenderer(this.host, { customHeading: true })(
item.messages[0].content + originalText
)}`
: nothing}
</div>
`
: nothing} `;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-wrapper': ActionWrapper;
}
}
@@ -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<EdgelessRootService>('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,
});
},
},
];
@@ -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;
}
}
@@ -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<typeof createButtonPopper> | 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<ChatContextValue>) => 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`<style>
.more-menu {
padding: ${this._showMoreMenu ? '8px' : '0px'};
}
</style>
<div class="copy-more">
${content
? html`<div @click=${() => copyText(host, content)}>
${CopyIcon}
<affine-tooltip>Copy</affine-tooltip>
</div>`
: nothing}
${isLast
? html`<div @click=${() => this._retry()}>
${RetryIcon}
<affine-tooltip>Retry</affine-tooltip>
</div>`
: nothing}
${isLast
? nothing
: html`<div class="more-button" @click=${this._toggle}>
${MoreIcon}
</div> `}
</div>
<div class="more-menu">
${this._showMoreMenu
? repeat(
PageEditorActions,
action => action.title,
action => {
return html`<div
@click=${() =>
action.handler(
host,
content,
this.curTextSelection,
this.curBlockSelections
)}
>
${action.icon}
<div>${action.title}</div>
</div>`;
}
)
: nothing}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'chat-copy-more': ChatCopyMore;
}
}
@@ -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`<action-wrapper .host=${this.host} .item=${this.item}>
<div style=${styleMap({ marginBottom: '12px' })}>
${answer ? renderImages(answer) : nothing}
</div>
</action-wrapper>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-image-to-text': ActionImageToText;
}
}
@@ -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`<action-wrapper .host=${this.host} .item=${this.item}>
<div style=${styleMap({ marginBottom: '12px' })}>
${answer ? renderImages(answer) : nothing}
</div>
</action-wrapper>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-image': ActionImage;
}
}
@@ -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`<action-wrapper .host=${this.host} .item=${this.item}>
<div style=${styleMap({ marginBottom: '12px' })}>
${createIframeRenderer(this.host)(answer, 'finished')}
</div>
</action-wrapper>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-make-real': ActionMakeReal;
}
}
@@ -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`<action-wrapper .host=${this.host} .item=${this.item}>
<div style=${styleMap({ marginBottom: '12px', height: '140px' })}>
<mini-mindmap-preview
.host=${this.host}
.ctx=${{
get: () => ({}),
set: () => {},
}}
.answer=${answer}
.templateShow=${false}
.height=${140}
></mini-mindmap-preview>
</div>
</action-wrapper>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-mindmap': ActionMindmap;
}
}
@@ -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`<action-wrapper .host=${this.host} .item=${this.item}>
<div style=${styleMap({ marginBottom: '12px', height: '174px' })}>
<ai-slides-renderer
.text=${answer}
.host=${this.host}
></ai-slides-renderer>
</div>
</action-wrapper>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-slides': ActionSlides;
}
}
@@ -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` <action-wrapper .host=${this.host} .item=${this.item}>
<div
style=${styleMap({
padding: isCode ? '0' : '10px 16px',
border: isCode ? 'none' : '1px solid var(--affine-border-color)',
})}
class="original-text"
>
${createTextRenderer(this.host, {
customHeading: true,
maxHeight: 160,
})(originalText)}
</div>
</action-wrapper>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'action-text': ActionText;
}
}
@@ -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`
<div class="generating-tip">
<div class="left">${AIStarIconWithAnimation}</div>
<div class="text">AI is generating...</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-loading': AILoading;
}
}
@@ -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`<div class="card-wrapper">
<div class="card-title">
${CurrentSelectionIcon}
<div>Start with current selection</div>
</div>
<div class="second-text">
${repeat(
lines.slice(0, 2),
line => line,
line => {
return html`<div
style=${styleMap({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
${line}
</div>`;
}
)}
</div>
</div> `;
},
handler: (
updateContext: (context: Partial<ChatContextValue>) => void,
text: string,
markdown: string,
images?: File[]
) => {
const value: Partial<ChatContextValue> = {
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`<div
class="card-wrapper"
style=${styleMap({
display: 'flex',
gap: '8px',
justifyContent: 'space-between',
})}
>
<div
style=${styleMap({
display: 'flex',
flexDirection: 'column',
})}
>
<div class="card-title">
${SmallImageIcon}
<div>Start with this Image</div>
</div>
<div class="second-text">${caption ? caption : 'caption'}</div>
</div>
<img
style=${styleMap({
maxWidth: '72px',
maxHeight: '46px',
})}
src="${URL.createObjectURL(image)}"
/>
</div>`;
},
handler: (
updateContext: (context: Partial<ChatContextValue>) => void,
_: string,
__: string,
images?: File[]
) => {
const value: Partial<ChatContextValue> = {};
if (images) {
value.images = images;
}
updateContext(value);
},
},
{
name: 'doc',
render: () => {
return html`
<div class="card-wrapper">
<div class="card-title">
${DocIcon}
<div>Start with this doc</div>
</div>
<div class="second-text">you've chosen within the doc</div>
</div>
`;
},
handler: (
updateContext: (context: Partial<ChatContextValue>) => void,
text: string,
markdown: string,
images?: File[]
) => {
const value: Partial<ChatContextValue> = {
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<ChatContextValue>) => 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`<div class="cards-container">
${repeat(
ChatCardsConfig,
card => card.name,
card => {
if (
card.render(this.text, this.images[0], this.caption) !== nothing
) {
return html`<div
@click=${async () => {
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)}
</div> `;
}
return nothing;
}
)}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'chat-cards': ChatCards;
}
}
@@ -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;
};
@@ -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<ChatContextValue>) => void;
@property({ attribute: false })
accessor cleanupHistories!: () => Promise<void>;
private _addImages(images: File[]) {
const oldImages = this.chatContextValue.images;
this.updateContext({
images: [...oldImages, ...images].slice(0, MaximumImageCount),
});
}
private _renderImages(images: File[]) {
return html`
<div
class="chat-panel-images"
@mouseleave=${() => {
this.closeWrapper.style.display = 'none';
this.curIndex = -1;
}}
>
${repeat(
images,
image => image.name,
(image, index) =>
html`<div
class="image-container"
@mouseenter=${(evt: MouseEvent) => {
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';
}}
>
<img src="${URL.createObjectURL(image)}" alt="${image.name}" />
</div>`
)}
<div
class="close-wrapper"
@click=${() => {
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}
</div>
</div>
`;
}
protected override render() {
const { images, status } = this.chatContextValue;
const hasImages = images.length > 0;
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
return html`<style>
.chat-panel-send svg rect {
fill: ${this.isInputEmpty && hasImages
? 'var(--affine-text-disable-color)'
: 'var(--affine-primary-color)'};
}
.chat-panel-input {
border-color: ${this.focused
? 'var(--affine-primary-color)'
: 'var(--affine-border-color)'};
box-shadow: ${this.focused ? 'var(--affine-active-shadow)' : 'none'};
max-height: ${maxHeight}px !important;
}
</style>
<div class="chat-panel-input">
${hasImages ? this._renderImages(images) : nothing}
${this.chatContextValue.quote
? html`<div class="chat-selection-quote">
${repeat(
getFirstTwoLines(this.chatContextValue.quote),
line => line,
line => html`<div>${line}</div>`
)}
<div
class="chat-quote-close"
@click=${() => {
this.updateContext({ quote: '', markdown: '' });
}}
>
${CloseIcon}
</div>
</div>`
: nothing}
<textarea
rows="1"
placeholder="What are your thoughts?"
@input=${() => {
const { textarea } = this;
this.isInputEmpty = !textarea.value.trim();
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
let imagesHeight = this.imagesWrapper?.scrollHeight ?? 0;
if (imagesHeight) imagesHeight += 12;
if (this.scrollHeight >= 200 + imagesHeight) {
textarea.style.height = '148px';
textarea.style.overflowY = 'scroll';
}
}}
@keydown=${async (evt: KeyboardEvent) => {
if (evt.key === 'Enter' && !evt.shiftKey && !evt.isComposing) {
evt.preventDefault();
await this.send();
}
}}
@focus=${() => {
this.focused = true;
}}
@blur=${() => {
this.focused = false;
}}
@paste=${(event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) return;
for (const index in items) {
const item = items[index];
if (item.kind === 'file' && item.type.indexOf('image') >= 0) {
const blob = item.getAsFile();
if (!blob) continue;
this._addImages([blob]);
}
}
}}
></textarea>
<div class="chat-panel-input-actions">
<div
class="chat-history-clear"
@click=${async () => {
await this.cleanupHistories();
}}
>
${ChatClearIcon}
</div>
${images.length < MaximumImageCount
? html`<div
class="image-upload"
@click=${async () => {
const images = await openFileOrFiles({
acceptType: 'Images',
multiple: true,
});
if (!images) return;
this._addImages(images);
}}
>
${ImageIcon}
</div>`
: nothing}
${status === 'transmitting'
? html`<div
@click=${() => {
this.chatContextValue.abortController?.abort();
this.updateContext({ status: 'success' });
reportResponse('aborted:stop');
}}
>
${ChatAbortIcon}
</div>`
: html`<div @click="${this.send}" class="chat-panel-send">
${ChatSendIcon}
</div>`}
</div>
</div>`;
}
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;
}
}
@@ -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<ChatContextValue>) => 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`<style>
.chat-panel-messages-placeholder div {
color: ${isLoading
? 'var(--affine-text-secondary-color)'
: 'var(--affine-text-primary-color)'};
font-size: ${isLoading ? 'var(--affine-font-sm)' : '18px'};
font-weight: 600;
}
</style>
<div
class="chat-panel-messages"
@scroll=${(evt: Event) => {
const element = evt.target as HTMLDivElement;
this.showDownIndicator =
element.scrollHeight - element.scrollTop - element.clientHeight >
200;
}}
>
${items.length === 0
? html`<div class="chat-panel-messages-placeholder">
${AffineIcon(
isLoading
? 'var(--affine-icon-secondary)'
: 'var(--affine-primary-color)'
)}
<div>
${this.isLoading
? 'AFFiNE AI is loading history...'
: 'What can I help you with?'}
</div>
</div>
<chat-cards
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
.host=${this.host}
.selectionValue=${this._selectionValue}
></chat-cards> `
: repeat(filteredItems, (item, index) => {
const isLast = index === filteredItems.length - 1;
return html`<div class="message">
${this.renderAvatar(item)}
<div class="item-wrapper">${this.renderItem(item, isLast)}</div>
</div>`;
})}
</div>
${this.showDownIndicator
? html`<div class="down-indicator" @click=${() => this.scrollToDown()}>
${DownArrowIcon}
</div>`
: 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`<div
style=${styleMap({
padding: '4px 12px',
borderRadius: '8px',
border: '1px solid var(--affine-border-color)',
cursor: 'pointer',
backgroundColor: 'var(--affine-hover-color)',
})}
@click=${() =>
AIProvider.slots.requestLogin.emit({ host: this.host })}
>
Login
</div>`
);
} 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`<chat-text
.host=${this.host}
.attachments=${item.attachments}
.text=${item.content}
.state=${state}
></chat-text>
${isLast && status === 'error' ? this.renderError() : nothing}
${this.renderEditorActions(item, isLast)}`;
} else {
switch (item.action) {
case 'Create a presentation':
return html`<action-slides
.host=${this.host}
.item=${item}
></action-slides>`;
case 'Make it real':
return html`<action-make-real
.host=${this.host}
.item=${item}
></action-make-real>`;
case 'Brainstorm mindmap':
return html`<action-mindmap
.host=${this.host}
.item=${item}
></action-mindmap>`;
case 'Explain this image':
case 'Generate a caption':
return html`<action-image-to-text
.host=${this.host}
.item=${item}
></action-image-to-text>`;
default:
if (HISTORY_IMAGE_ACTIONS.includes(item.action)) {
return html`<action-image
.host=${this.host}
.item=${item}
></action-image>`;
}
return html`<action-text
.item=${item}
.host=${this.host}
.isCode=${item.action === 'Explain this code' ||
item.action === 'Check code error'}
></action-text>`;
}
}
}
renderAvatar(item: ChatItem) {
const isUser = 'role' in item && item.role === 'user';
return html`<div class="user-info">
${isUser
? html`<div class="avatar-container">
${this.avatarUrl
? html`<img .src=${this.avatarUrl} />`
: html`<div class="avatar"></div>`}
</div>`
: AffineAvatarIcon}
${isUser ? 'You' : 'AFFINE AI'}
</div>`;
}
renderLoading() {
return html` <ai-loading></ai-loading>`;
}
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`
<style>
.actions-container {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
margin-top: 8px;
}
.actions-container > div {
display: flex;
gap: 8px;
}
.action {
width: fit-content;
height: 32px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background-color: var(--affine-white-10);
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
font-size: 15px;
font-weight: 500;
color: var(--affine-text-primary-color);
cursor: pointer;
user-select: none;
}
.action svg {
color: var(--affine-icon-color);
}
</style>
<chat-copy-more
.host=${host}
.content=${content}
.isLast=${isLast}
.curTextSelection=${this._currentTextSelection}
.curBlockSelections=${this._currentBlockSelections}
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
></chat-copy-more>
${isLast
? html`<div class="actions-container">
${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`<div class="action">
${action.icon}
<div
@click=${async () => {
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}
</div>
</div>`;
}
)}
</div>`
: nothing}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'chat-panel-messages': ChatPanelMessages;
}
}
@@ -0,0 +1,41 @@
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
export const renderImages = (images: string[]) => {
return html`<style>
.images-container {
display: flex;
gap: 12px;
flex-direction: column;
margin-bottom: 8px;
}
.image-container {
border-radius: 4px;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 70%;
max-width: 320px;
img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
}
</style>
<div class="images-container">
${repeat(
images,
image => image,
image => {
return html`<div class="image-container">
<img src="${image}" />
</div>`;
}
)}
</div>`;
};
@@ -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',
];
@@ -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<ChatPanelMessages> =
createRef<ChatPanelMessages>();
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<ChatContextValue>) => {
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` <div class="chat-panel-container">
<div class="chat-panel-title">
<div>AFFINE AI</div>
<div
@click=${() => {
AIProvider.toggleGeneralAIOnboarding?.(true);
}}
>
${AIHelpIcon}
</div>
</div>
<chat-panel-messages
${ref(this._chatMessages)}
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
.host=${this.host}
.isLoading=${this.isLoading}
></chat-panel-messages>
<chat-panel-input
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
.host=${this.host}
.cleanupHistories=${this._cleanupHistories}
></chat-panel-input>
<div class="chat-panel-footer">
${SmallHintIcon}
<div>AI outputs can be misleading or wrong</div>
</div>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'chat-panel': ChatPanel;
}
}
@@ -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<EdgelessRootService>('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 ?? '';
};
@@ -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`<ask-ai-button
class="code-toolbar-button ask-ai"
.host=${codeBlock.host}
.actionGroups=${AICodeItemGroups}
.toggleType=${'click'}
.options=${buttonOptions}
@click=${(e: MouseEvent) => {
e.stopPropagation();
onAskAIClick();
onClick?.();
}}
></ask-ai-button>`;
},
},
],
0
);
}
@@ -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,
];
@@ -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`<edgeless-copilot-toolbar-entry
.edgeless=${edgeless}
.host=${edgeless.host}
.groups=${edgelessActionGroups}
></edgeless-copilot-toolbar-entry>`;
},
});
}
@@ -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` <ask-ai-button
.host=${formatBar.host}
.actionGroups=${AIItemGroups}
.toggleType=${'hover'}
></ask-ai-button>`;
},
},
{ type: 'divider' },
],
0
);
}
@@ -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`<ask-ai-button
class="image-toolbar-button ask-ai"
.host=${imageBlock.host}
.actionGroups=${AIImageItemGroups}
.toggleType=${'click'}
.options=${buttonOptions}
@click=${(e: MouseEvent) => {
e.stopPropagation();
onAskAIClick();
onClick?.();
}}
></ask-ai-button>`;
},
showWhen: () => true,
},
],
0
);
}
@@ -0,0 +1,2 @@
export * from './format-bar/setup-format-bar.js';
export * from './space/setup-space.js';
@@ -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`<div style="color: var(--affine-primary-color)">
${typeof icon === 'function' ? html`${icon()}` : icon}
</div>`;
};
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<AffineSlashMenuActionItem>(
({ 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,
};
}
@@ -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);
}
}
});
}
@@ -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';
@@ -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` <style>
.answer-tip {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 4px;
align-self: stretch;
border-radius: 4px;
padding: 8px;
background-color: var(--affine-background-error-color);
.bottom {
align-items: flex-start;
display: flex;
gap: 8px;
align-self: stretch;
color: var(--affine-error-color, #eb4335);
font-feature-settings:
'clig' off,
'liga' off;
/* light/sm */
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
margin-bottom: 4px;
a {
color: inherit;
}
div svg {
position: relative;
top: 3px;
}
}
}
</style>
<div class="answer-tip">
<div class="bottom">
<div>${ErrorTipIcon}</div>
<div>${this.text}</div>
</div>
<slot></slot>
</div>`;
}
}
export const PaymentRequiredErrorRenderer = (host: EditorHost) => html`
<style>
.upgrade {
cursor: pointer;
display: flex;
padding: 4px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 8px;
margin-left: auto;
border: 1px solid var(--affine-border-color, #e3e2e4);
background: var(--affine-primary-color);
.content {
display: flex;
padding: 0px 4px;
justify-content: center;
align-items: center;
color: var(--affine-pure-white);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
</style>
<ai-error-wrapper
.text=${html`You've reached the current usage cap for AFFiNE AI. You can
subscribe to AFFiNE AI to continue the AI experience!`}
>
<div
@click=${() => AIProvider.slots.requestUpgradePlan.emit({ host: host })}
class="upgrade"
>
<div class="content">Upgrade</div>
</div></ai-error-wrapper
>
`;
export const GeneralErrorRenderer = (
text: TemplateResult<1> = html`An error occurred, If this issue persists
please let us know.
<a href="mailto:support@toeverything.info"> support@toeverything.info </a>`,
template: TemplateResult<1> = html`${nothing}`
) => html` <ai-error-wrapper .text=${text}>${template}</ai-error-wrapper>`;
declare global {
interface HTMLElementTagNameMap {
'ai-error-wrapper': AIErrorWrapper;
}
}
@@ -0,0 +1,2 @@
export * from './text.js';
export * from './wrapper.js';
@@ -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<string, unknown>;
set: (data: Record<string, unknown>) => 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`<mini-mindmap-preview
.ctx=${ctx}
.host=${host}
.answer=${answer}
.mindmapStyle=${style}
.templateShow=${style === undefined}
.height=${300}
></mini-mindmap-preview>`;
};
};
/**
* 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<string, unknown>;
set: (data: Record<string, unknown>) => void;
},
handler: (ctx: {
get: () => Record<string, unknown>;
set: (data: Record<string, unknown>) => 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;
};
};
@@ -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<string, unknown>;
set: (data: Record<string, unknown>) => 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`<style>
.slides-container {
width: 100%;
height: 300px;
}
</style>
<div class="slides-container">
<ai-slides-renderer
.host=${host}
.ctx=${ctx}
.text=${answer}
></ai-slides-renderer>
</div>`;
};
};
@customElement('ai-slides-renderer')
export class AISlidesRenderer extends WithDisposable(LitElement) {
static override styles = css``;
private readonly _editorContainer: Ref<HTMLDivElement> =
createRef<HTMLDivElement>();
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<string, unknown>;
set(data: Record<string, unknown>): 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`<style>
.slides-container {
position: relative;
width: 100%;
height: 100%;
}
.edgeless-container {
width: 100%;
height: 100%;
border-radius: 4px;
border: 1px solid var(--affine-border-color);
}
.mask {
position: absolute;
top: 0;
left: 0;
z-index: 1;
background-color: transparent;
width: 100%;
height: 100%;
}
.edgeless-container affine-edgeless-zoom-toolbar-widget,
edgeless-toolbar {
display: none;
}
* {
box-sizing: border-box;
}
.affine-edgeless-viewport {
display: block;
height: 100%;
position: relative;
overflow: hidden;
container-name: viewport;
container-type: inline-size;
}
.affine-edgeless-surface-block-container {
position: absolute;
width: 100%;
height: 100%;
}
.affine-edgeless-surface-block-container canvas {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
pointer-events: none;
}
edgeless-block-portal-container {
position: relative;
box-sizing: border-box;
overflow: hidden;
display: block;
height: 100%;
font-family: var(--affine-font-family);
font-size: var(--affine-font-base);
line-height: var(--affine-line-height);
color: var(--affine-text-primary-color);
font-weight: 400;
}
.affine-block-children-container.edgeless {
padding-left: 0;
position: relative;
overflow: hidden;
height: 100%;
touch-action: none;
background-color: var(--affine-background-primary-color);
background-image: radial-gradient(
var(--affine-edgeless-grid-color) 1px,
var(--affine-background-primary-color) 1px
);
z-index: 0;
}
.affine-edgeless-block-child {
position: absolute;
transform-origin: center;
box-sizing: border-box;
border: 2px solid var(--affine-white-10);
border-radius: 8px;
box-shadow: var(--affine-shadow-3);
pointer-events: all;
}
affine-edgeless-image .resizable-img,
affine-edgeless-image .resizable-img img {
width: 100%;
height: 100%;
}
.affine-edgeless-layer {
position: absolute;
top: 0;
left: 0;
contain: size layout style;
}
</style>
<div class="slides-container">
<div
class="edgeless-container affine-edgeless-viewport"
${ref(this._editorContainer)}
>
${this.host.renderSpecPortal(this._doc, EdgelessEditorBlockSpecs)}
</div>
<div class="mask"></div>
</div>`;
}
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;
}
}
@@ -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<typeof setInterval> | 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`
<style>
.ai-answer-text-container {
max-height: ${maxHeight ? Math.max(maxHeight, 200) + 'px' : ''};
}
</style>
<div class=${classes} @wheel=${this._onWheel}>
${keyed(
this._doc,
html`<div class="ai-answer-text-editor affine-page-viewport">
${this.host.renderSpecPortal(this._doc, CustomPageEditorBlockSpecs)}
</div>`
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-answer-text': AIAnswerText;
}
}
export const createTextRenderer: (
host: EditorHost,
options: TextRendererOptions
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => {
return (answer, state) => {
return html`<ai-answer-text
.host=${host}
.answer=${answer}
.state=${state}
.options=${options}
></ai-answer-text>`;
};
};
@@ -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`<style>
:host {
height: ${this.options?.height
? this.options?.height + 'px'
: '100%'};
}
</style>
<slot></slot> `;
}
}
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`<iframe
class="ai-answer-iframe"
sandbox="allow-scripts"
scrolling="no"
allowfullscreen
.srcdoc=${preprocessHtml(answer)}
>
</iframe>`;
return html`<ai-answer-wrapper .options=${options}
>${template}</ai-answer-wrapper
>`;
};
};
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`<style>
.ai-answer-image img{
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
<div class="ai-answer-image">
<img src=${answer}></img>
</div>`;
return html`<ai-answer-wrapper .options=${options}
>${template}</ai-answer-wrapper
>`;
};
};
@@ -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<BlockSuitePresets.AIActions> = {};
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<AIUserInfo | null>(),
// 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<AIUserInfo> | null = () =>
null;
private provideAction<T extends keyof BlockSuitePresets.AIActions>(
id: T,
action: (
...options: Parameters<BlockSuitePresets.AIActions[T]>
) => ReturnType<BlockSuitePresets.AIActions[T]>
): 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<BlockSuitePresets.AIActions[T]>
) => {
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<string> = action(
...args
);
const isTextStream = (
m: BlockSuitePresets.TextStream | Promise<string>
): 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<AIUserInfo> | 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<T extends keyof BlockSuitePresets.AIActions>(
id: T,
action: (
...options: Parameters<BlockSuitePresets.AIActions[T]>
) => ReturnType<BlockSuitePresets.AIActions[T]>
): 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);
}
}
}
@@ -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<EdgelessRootService>('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 ?? '';
};
@@ -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<string, string>, 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<string> => {
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<string, (w: number, h: number) => Promise<string> | string>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
template: any
): Promise<TemplateImage[]> => {
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<DocTemplate> => {
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<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
const basic1section = async (
title: string,
section1: PPTSection
): Promise<DocTemplate> => {
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<DocTemplate> => {
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<DocTemplate> => {
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<DocTemplate> => {
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]
);
};
@@ -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": []
}
]
}
]
}
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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": []
}
]
}
]
}
}
]
@@ -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,
});
}
@@ -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<string>();
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<string>();
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;
};
@@ -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;
});
@@ -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;
}
@@ -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;
};
@@ -0,0 +1,5 @@
export function preprocessHtml(answer: string) {
const start = answer.indexOf('<!DOCTYPE html>');
const end = answer.indexOf('</html>');
return answer.slice(start, end + '</html>'.length);
}
@@ -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<File | void> {
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<File | void> {
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<Blob | null> {
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<string>((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<Blob | null>(resolve =>
canvas.toBlob(resolve, type, quality)
);
}
export function randomSeed(min = 0, max = Date.now()) {
return Math.round(Math.random() * (max - min)) + min;
}
@@ -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<string, unknown>).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;
}
@@ -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],
};
};
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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) {
@@ -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,
@@ -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';
@@ -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 {
@@ -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 {
+1 -1
View File
@@ -95,4 +95,4 @@
"peerDependencies": {
"ts-node": "*"
}
}
}
+1 -1
View File
@@ -46,4 +46,4 @@
"dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts"
},
"version": "0.14.0"
}
}
+1
View File
@@ -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(
+24 -4
View File
@@ -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: {
+25 -2
View File
@@ -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: