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
No known key found for this signature in database
GPG Key ID: 4B5C21CB76DF6E92
84 changed files with 17228 additions and 30 deletions

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",

View File

@ -27,4 +27,4 @@
"zod": "^3.22.4"
},
"version": "0.14.0"
}
}

View File

@ -68,4 +68,4 @@
}
},
"version": "0.14.0"
}
}

View File

@ -109,4 +109,4 @@
"yjs": "^13.6.14"
},
"version": "0.14.0"
}
}

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"
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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'];

View File

@ -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);
}

View File

@ -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]);
}

View File

@ -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)),
],
},
],
};
}

View File

@ -0,0 +1,2 @@
export * from './doc-handler.js';
export * from './types.js';

View File

@ -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[]>;
}
}
}

View File

@ -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;
};

View File

@ -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);
}
});
},
};

View File

@ -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;
}
}

View File

@ -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,
});
},
},
];

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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>`;
};

View File

@ -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',
];

View File

@ -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;
}
}

View File

@ -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 ?? '';
};

View File

@ -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
);
}

View File

@ -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,
];

View File

@ -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>`;
},
});
}

View File

@ -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
);
}

View File

@ -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
);
}

View File

@ -0,0 +1,2 @@
export * from './format-bar/setup-format-bar.js';
export * from './space/setup-space.js';

View File

@ -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,
};
}

View File

@ -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);
}
}
});
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -0,0 +1,2 @@
export * from './text.js';
export * from './wrapper.js';

View File

@ -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;
};
};

View File

@ -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;
}
}

View File

@ -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>`;
};
};

View File

@ -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
>`;
};
};

View File

@ -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);
}
}
}

View File

@ -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 ?? '';
};

View File

@ -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]
);
};

View File

@ -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

View File

@ -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": []
}
]
}
]
}
}
]

View File

@ -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,
});
}

View File

@ -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;
};

View File

@ -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;
});

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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],
};
};

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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) {

View File

@ -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,

View File

@ -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';

View File

@ -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 {

View File

@ -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 {

View File

@ -95,4 +95,4 @@
"peerDependencies": {
"ts-node": "*"
}
}
}

View File

@ -46,4 +46,4 @@
"dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts"
},
"version": "0.14.0"
}
}

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(

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: {

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: